Compare commits
66 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
|
|
@ -9,7 +9,8 @@ RUN apk add --no-cache composer
|
||||||
# Add Chromium and Image Magick for puppeteer.
|
# Add Chromium and Image Magick for puppeteer.
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
imagemagick-dev \
|
imagemagick-dev \
|
||||||
chromium
|
chromium \
|
||||||
|
libzip-dev
|
||||||
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENV PUPPETEER_DOCKER=1
|
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
|
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
|
# 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
|
# Composer uses its php binary, but we want it to use the container's one
|
||||||
RUN rm -f /usr/bin/php84
|
RUN rm -f /usr/bin/php84
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,8 @@ RUN apk add --no-cache \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
imagemagick-dev \
|
imagemagick-dev \
|
||||||
chromium
|
chromium \
|
||||||
|
libzip-dev
|
||||||
|
|
||||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||||
ENV PUPPETEER_DOCKER=1
|
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
|
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
|
# Install PHP extensions
|
||||||
RUN docker-php-ext-install imagick
|
RUN docker-php-ext-install imagick zip
|
||||||
|
|
||||||
RUN rm -f /usr/bin/php84
|
RUN rm -f /usr/bin/php84
|
||||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||||
|
|
|
||||||
5
.gitignore
vendored
|
|
@ -29,3 +29,8 @@ yarn-error.log
|
||||||
/.junie/guidelines.md
|
/.junie/guidelines.md
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
/.mcp.json
|
/.mcp.json
|
||||||
|
/.ai
|
||||||
|
.DS_Store
|
||||||
|
/boost.json
|
||||||
|
/.gemini
|
||||||
|
/GEMINI.md
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
########################
|
########################
|
||||||
# Base Image
|
# 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.source=https://github.com/usetrmnl/byos_laravel
|
||||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
[](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.
|
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||||
It allows you to manage TRMNL devices, generate screens using native plugins, recipes (100+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS.
|
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,7 +16,8 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re
|
||||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||||
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
||||||
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
|
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
|
||||||
* Over 45 compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
* 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
|
* Supported Devices
|
||||||
* TRMNL OG (1-bit & 2-bit)
|
* TRMNL OG (1-bit & 2-bit)
|
||||||
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
||||||
|
|
@ -24,6 +25,7 @@ It allows you to manage TRMNL devices, generate screens using native plugins, re
|
||||||
* reTerminal E1001 Monochrome ePaper Display
|
* reTerminal E1001 Monochrome ePaper Display
|
||||||
* Custom ESP32 with TRMNL firmware
|
* Custom ESP32 with TRMNL firmware
|
||||||
* E-Reader Devices
|
* E-Reader Devices
|
||||||
|
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
|
||||||
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
|
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
|
||||||
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
|
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
|
||||||
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
|
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,10 @@ class GenerateDefaultImagesCommand extends Command
|
||||||
|
|
||||||
$browserStage = new BrowserStage($browsershotInstance);
|
$browserStage = new BrowserStage($browsershotInstance);
|
||||||
$browserStage->html($html);
|
$browserStage->html($html);
|
||||||
|
|
||||||
|
// Set timezone from app config (no user context in this command)
|
||||||
|
$browserStage->timezone(config('app.timezone'));
|
||||||
|
|
||||||
$browserStage
|
$browserStage
|
||||||
->width($deviceModel->width)
|
->width($deviceModel->width)
|
||||||
->height($deviceModel->height);
|
->height($deviceModel->height);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\DeviceModel;
|
use App\Models\DeviceModel;
|
||||||
|
use App\Models\DevicePalette;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
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 API_URL = 'https://usetrmnl.com/api/models';
|
||||||
|
|
||||||
|
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* Create a new job instance.
|
||||||
*/
|
*/
|
||||||
|
|
@ -34,6 +37,8 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$this->processPalettes();
|
||||||
|
|
||||||
$response = Http::timeout(30)->get(self::API_URL);
|
$response = Http::timeout(30)->get(self::API_URL);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
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.
|
* 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_x' => $modelData['offset_x'] ?? 0,
|
||||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||||
'published_at' => $modelData['published_at'] ?? null,
|
'published_at' => $modelData['published_at'] ?? null,
|
||||||
|
'kind' => $modelData['kind'] ?? null,
|
||||||
'source' => 'api',
|
'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(
|
DeviceModel::updateOrCreate(
|
||||||
['name' => $name],
|
['name' => $name],
|
||||||
$attributes
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,6 @@ class Data extends FiltersProvider
|
||||||
*/
|
*/
|
||||||
public function map_to_i(array $input): array
|
public function map_to_i(array $input): array
|
||||||
{
|
{
|
||||||
return array_map('intval', $input);
|
return array_map(intval(...), $input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property-read DeviceModel|null $deviceModel
|
* @property-read DeviceModel|null $deviceModel
|
||||||
|
* @property-read DevicePalette|null $palette
|
||||||
*/
|
*/
|
||||||
class Device extends Model
|
class Device extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -19,6 +20,14 @@ class Device extends Model
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
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 = [
|
protected $casts = [
|
||||||
'battery_notification_sent' => 'boolean',
|
'battery_notification_sent' => 'boolean',
|
||||||
'proxy_cloud' => 'boolean',
|
'proxy_cloud' => 'boolean',
|
||||||
|
|
@ -187,6 +196,11 @@ class Device extends Model
|
||||||
return $this->belongsTo(DeviceModel::class);
|
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.
|
* Get the color depth string (e.g., "4bit") for the associated device model.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@ namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @property-read DevicePalette|null $palette
|
||||||
|
*/
|
||||||
final class DeviceModel extends Model
|
final class DeviceModel extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
@ -35,7 +39,7 @@ final class DeviceModel extends Model
|
||||||
return '2bit';
|
return '2bit';
|
||||||
}
|
}
|
||||||
|
|
||||||
// if higher then 4 return 4bit
|
// if higher than 4 return 4bit
|
||||||
if ($this->bit_depth > 4) {
|
if ($this->bit_depth > 4) {
|
||||||
return '4bit';
|
return '4bit';
|
||||||
}
|
}
|
||||||
|
|
@ -66,4 +70,9 @@ final class DeviceModel extends Model
|
||||||
|
|
||||||
return null;
|
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,21 +37,32 @@ class Playlist extends Model
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check weekday
|
// Get user's timezone or fall back to app timezone
|
||||||
if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) {
|
$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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->active_from !== null && $this->active_until !== null) {
|
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
|
// 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)
|
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||||
if ($now >= $this->active_from || $now <= $this->active_until) {
|
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} elseif ($now >= $this->active_from && $now <= $this->active_until) {
|
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,9 @@ use App\Liquid\Filters\StandardFilters;
|
||||||
use App\Liquid\Filters\StringMarkup;
|
use App\Liquid\Filters\StringMarkup;
|
||||||
use App\Liquid\Filters\Uniqueness;
|
use App\Liquid\Filters\Uniqueness;
|
||||||
use App\Liquid\Tags\TemplateTag;
|
use App\Liquid\Tags\TemplateTag;
|
||||||
|
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
@ -22,10 +24,10 @@ use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Process;
|
use Illuminate\Support\Facades\Process;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||||
use SimpleXMLElement;
|
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -43,6 +45,8 @@ class Plugin extends Model
|
||||||
'no_bleed' => 'boolean',
|
'no_bleed' => 'boolean',
|
||||||
'dark_mode' => 'boolean',
|
'dark_mode' => 'boolean',
|
||||||
'preferred_renderer' => 'string',
|
'preferred_renderer' => 'string',
|
||||||
|
'plugin_type' => 'string',
|
||||||
|
'alias' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
|
|
@ -54,6 +58,18 @@ class Plugin extends Model
|
||||||
$model->uuid = Str::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()
|
public function user()
|
||||||
|
|
@ -61,6 +77,25 @@ class Plugin extends Model
|
||||||
return $this->belongsTo(User::class);
|
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
|
public function hasMissingRequiredConfigurationFields(): bool
|
||||||
{
|
{
|
||||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||||
|
|
@ -101,6 +136,11 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function isDataStale(): bool
|
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') {
|
if ($this->data_strategy === 'webhook') {
|
||||||
// Treat as stale if any webhook event has occurred in the past hour
|
// 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());
|
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||||
|
|
@ -114,161 +154,88 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function updateDataPayload(): void
|
public function updateDataPayload(): void
|
||||||
{
|
{
|
||||||
if ($this->data_strategy === 'polling' && $this->polling_url) {
|
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||||
|
return;
|
||||||
$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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve Liquid variables in the entire polling_url field first, then split by newline
|
|
||||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
|
||||||
$urls = array_filter(
|
|
||||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
|
||||||
fn ($url): bool => ! 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL is already resolved, use it directly
|
|
||||||
$resolvedUrl = $url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Make the request based on the verb
|
|
||||||
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
|
||||||
|
|
||||||
$response = $this->parseResponse($httpResponse);
|
|
||||||
|
|
||||||
$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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL is already resolved, use it directly
|
|
||||||
$resolvedUrl = $url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Make the request based on the verb
|
|
||||||
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
|
||||||
|
|
||||||
$response = $this->parseResponse($httpResponse);
|
|
||||||
|
|
||||||
// Check if response is an array at root level
|
|
||||||
if (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(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse HTTP response, handling both JSON and XML content types
|
|
||||||
*/
|
|
||||||
private function parseResponse(Response $httpResponse): array
|
private function parseResponse(Response $httpResponse): array
|
||||||
{
|
{
|
||||||
if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) {
|
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
||||||
|
|
||||||
|
foreach ($parsers as $parser) {
|
||||||
|
$parserName = class_basename($parser);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Convert XML to array and wrap under 'rss' key
|
$result = $parser->parse($httpResponse);
|
||||||
$xml = simplexml_load_string($httpResponse->body());
|
|
||||||
if ($xml === false) {
|
if ($result !== null) {
|
||||||
throw new Exception('Invalid XML content');
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert SimpleXML directly to array
|
|
||||||
$xmlArray = $this->xmlToArray($xml);
|
|
||||||
|
|
||||||
return ['rss' => $xmlArray];
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::warning('Failed to parse XML response: '.$e->getMessage());
|
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||||
|
|
||||||
return ['error' => 'Failed to parse XML response'];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return ['error' => 'Failed to parse response'];
|
||||||
// Attempt to parse it into JSON
|
|
||||||
$json = $httpResponse->json();
|
|
||||||
if ($json !== null) {
|
|
||||||
return $json;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response doesn't seem to be JSON, wrap the response body text as a JSON object
|
|
||||||
return ['data' => $httpResponse->body()];
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
|
||||||
|
|
||||||
return ['error' => 'Failed to parse JSON response'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert SimpleXML object to array recursively
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -450,10 +417,20 @@ class Plugin extends Model
|
||||||
*/
|
*/
|
||||||
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): 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) {
|
if ($this->render_markup) {
|
||||||
$renderedContent = '';
|
$renderedContent = '';
|
||||||
|
|
||||||
if ($this->markup_language === 'liquid') {
|
if ($this->markup_language === 'liquid') {
|
||||||
|
// Get timezone from user or fall back to app timezone
|
||||||
|
$timezone = $this->user->timezone ?? config('app.timezone');
|
||||||
|
|
||||||
|
// Calculate UTC offset in seconds
|
||||||
|
$utcOffset = (string) Carbon::now($timezone)->getOffset();
|
||||||
|
|
||||||
// Build render context
|
// Build render context
|
||||||
$context = [
|
$context = [
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
|
|
@ -465,10 +442,10 @@ class Plugin extends Model
|
||||||
'timestamp_utc' => now()->utc()->timestamp,
|
'timestamp_utc' => now()->utc()->timestamp,
|
||||||
],
|
],
|
||||||
'user' => [
|
'user' => [
|
||||||
'utc_offset' => '0',
|
'utc_offset' => $utcOffset,
|
||||||
'name' => $this->user->name ?? 'Unknown User',
|
'name' => $this->user->name ?? 'Unknown User',
|
||||||
'locale' => 'en',
|
'locale' => 'en',
|
||||||
'time_zone_iana' => config('app.timezone'),
|
'time_zone_iana' => $timezone,
|
||||||
],
|
],
|
||||||
'plugin_settings' => [
|
'plugin_settings' => [
|
||||||
'instance_name' => $this->name,
|
'instance_name' => $this->name,
|
||||||
|
|
@ -551,17 +528,30 @@ class Plugin extends Model
|
||||||
|
|
||||||
if ($this->render_markup_view) {
|
if ($this->render_markup_view) {
|
||||||
if ($standalone) {
|
if ($standalone) {
|
||||||
return view('trmnl-layouts.single', [
|
$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(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'noBleed' => $this->no_bleed,
|
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'slot' => view($this->render_markup_view, [
|
'slot' => $renderedView,
|
||||||
'size' => $size,
|
|
||||||
'data' => $this->data_payload,
|
|
||||||
'config' => $this->configuration ?? [],
|
|
||||||
])->render(),
|
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -592,4 +582,61 @@ class Plugin extends Model
|
||||||
default => '1Tx1B',
|
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_devices',
|
||||||
'assign_new_device_id',
|
'assign_new_device_id',
|
||||||
'oidc_sub',
|
'oidc_sub',
|
||||||
|
'timezone',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,45 @@ class ImageGenerationService
|
||||||
{
|
{
|
||||||
public static function generateImage(string $markup, $deviceId): string
|
public static function generateImage(string $markup, $deviceId): string
|
||||||
{
|
{
|
||||||
$device = Device::with('deviceModel')->find($deviceId);
|
$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");
|
||||||
|
|
||||||
|
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();
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
||||||
$imageSettings = self::getImageSettings($device);
|
$imageSettings = $deviceModel
|
||||||
|
? self::getImageSettingsFromModel($deviceModel)
|
||||||
|
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
|
||||||
|
|
||||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
@ -44,6 +77,10 @@ class ImageGenerationService
|
||||||
$browserStage = new BrowserStage($browsershotInstance);
|
$browserStage = new BrowserStage($browsershotInstance);
|
||||||
$browserStage->html($markup);
|
$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') {
|
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||||
$browserStage
|
$browserStage
|
||||||
->width($imageSettings['width'])
|
->width($imageSettings['width'])
|
||||||
|
|
@ -61,6 +98,14 @@ class ImageGenerationService
|
||||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
$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 = new ImageStage();
|
||||||
$imageStage->format($fileExtension)
|
$imageStage->format($fileExtension)
|
||||||
->width($imageSettings['width'])
|
->width($imageSettings['width'])
|
||||||
|
|
@ -72,6 +117,11 @@ class ImageGenerationService
|
||||||
->offsetY($imageSettings['offset_y'])
|
->offsetY($imageSettings['offset_y'])
|
||||||
->outputPath($outputPath);
|
->outputPath($outputPath);
|
||||||
|
|
||||||
|
// Apply color palette if available
|
||||||
|
if ($colorPalette) {
|
||||||
|
$imageStage->colormap($colorPalette);
|
||||||
|
}
|
||||||
|
|
||||||
// Apply dithering if requested by markup
|
// Apply dithering if requested by markup
|
||||||
$shouldDither = self::markupContainsDitherImage($markup);
|
$shouldDither = self::markupContainsDitherImage($markup);
|
||||||
if ($shouldDither) {
|
if ($shouldDither) {
|
||||||
|
|
@ -90,8 +140,7 @@ class ImageGenerationService
|
||||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
$device->update(['current_screen_image' => $uuid]);
|
Log::info("Generated image: $uuid");
|
||||||
Log::info("Device $device->id: updated with new image: $uuid");
|
|
||||||
|
|
||||||
return $uuid;
|
return $uuid;
|
||||||
|
|
||||||
|
|
@ -108,22 +157,7 @@ class ImageGenerationService
|
||||||
{
|
{
|
||||||
// If device has a DeviceModel, use its settings
|
// If device has a DeviceModel, use its settings
|
||||||
if ($device->deviceModel) {
|
if ($device->deviceModel) {
|
||||||
/** @var DeviceModel $model */
|
return self::getImageSettingsFromModel($device->deviceModel);
|
||||||
$model = $device->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),
|
|
||||||
'use_model_settings' => true,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to device settings
|
// Fallback to device settings
|
||||||
|
|
@ -147,6 +181,43 @@ class ImageGenerationService
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get image generation settings from a DeviceModel
|
||||||
|
*/
|
||||||
|
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
|
||||||
|
{
|
||||||
|
if ($deviceModel) {
|
||||||
|
return [
|
||||||
|
'width' => $deviceModel->width,
|
||||||
|
'height' => $deviceModel->height,
|
||||||
|
'colors' => $deviceModel->colors,
|
||||||
|
'bit_depth' => $deviceModel->bit_depth,
|
||||||
|
'scale_factor' => $deviceModel->scale_factor,
|
||||||
|
'rotation' => $deviceModel->rotation,
|
||||||
|
'mime_type' => $deviceModel->mime_type,
|
||||||
|
'offset_x' => $deviceModel->offset_x,
|
||||||
|
'offset_y' => $deviceModel->offset_y,
|
||||||
|
'image_format' => self::determineImageFormatFromModel($deviceModel),
|
||||||
|
'use_model_settings' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default settings if no device model provided
|
||||||
|
return [
|
||||||
|
'width' => 800,
|
||||||
|
'height' => 480,
|
||||||
|
'colors' => 2,
|
||||||
|
'bit_depth' => 1,
|
||||||
|
'scale_factor' => 1.0,
|
||||||
|
'rotation' => 0,
|
||||||
|
'mime_type' => 'image/png',
|
||||||
|
'offset_x' => 0,
|
||||||
|
'offset_y' => 0,
|
||||||
|
'image_format' => ImageFormat::AUTO->value,
|
||||||
|
'use_model_settings' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the appropriate ImageFormat based on DeviceModel settings
|
* Determine the appropriate ImageFormat based on DeviceModel settings
|
||||||
*/
|
*/
|
||||||
|
|
@ -263,6 +334,10 @@ class ImageGenerationService
|
||||||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||||
{
|
{
|
||||||
if ($plugin?->id) {
|
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
|
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||||
$hasCustomDimensions = Device::query()
|
$hasCustomDimensions = Device::query()
|
||||||
->where(function ($query): void {
|
->where(function ($query): void {
|
||||||
|
|
@ -294,7 +369,7 @@ class ImageGenerationService
|
||||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -328,16 +403,19 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate a default screen image from Blade template
|
* Generate a default screen image from Blade template
|
||||||
*/
|
*/
|
||||||
public static function generateDefaultScreenImage(Device $device, string $imageType): string
|
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$uuid = Uuid::uuid4()->toString();
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Load device with relationships
|
||||||
|
$device->load(['palette', 'deviceModel.palette', 'user']);
|
||||||
|
|
||||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||||
$imageSettings = self::getImageSettings($device);
|
$imageSettings = self::getImageSettings($device);
|
||||||
|
|
||||||
|
|
@ -345,7 +423,7 @@ class ImageGenerationService
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
||||||
// Generate HTML from Blade template
|
// Generate HTML from Blade template
|
||||||
$html = self::generateDefaultScreenHtml($device, $imageType);
|
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||||
|
|
||||||
// Create custom Browsershot instance if using AWS Lambda
|
// Create custom Browsershot instance if using AWS Lambda
|
||||||
$browsershotInstance = null;
|
$browsershotInstance = null;
|
||||||
|
|
@ -356,6 +434,10 @@ class ImageGenerationService
|
||||||
$browserStage = new BrowserStage($browsershotInstance);
|
$browserStage = new BrowserStage($browsershotInstance);
|
||||||
$browserStage->html($html);
|
$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') {
|
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||||
$browserStage
|
$browserStage
|
||||||
->width($imageSettings['width'])
|
->width($imageSettings['width'])
|
||||||
|
|
@ -372,6 +454,14 @@ class ImageGenerationService
|
||||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
$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 = new ImageStage();
|
||||||
$imageStage->format($fileExtension)
|
$imageStage->format($fileExtension)
|
||||||
->width($imageSettings['width'])
|
->width($imageSettings['width'])
|
||||||
|
|
@ -383,6 +473,11 @@ class ImageGenerationService
|
||||||
->offsetY($imageSettings['offset_y'])
|
->offsetY($imageSettings['offset_y'])
|
||||||
->outputPath($outputPath);
|
->outputPath($outputPath);
|
||||||
|
|
||||||
|
// Apply color palette if available
|
||||||
|
if ($colorPalette) {
|
||||||
|
$imageStage->colormap($colorPalette);
|
||||||
|
}
|
||||||
|
|
||||||
(new TrmnlPipeline())->pipe($browserStage)
|
(new TrmnlPipeline())->pipe($browserStage)
|
||||||
->pipe($imageStage)
|
->pipe($imageStage)
|
||||||
->process();
|
->process();
|
||||||
|
|
@ -408,12 +503,13 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate HTML from Blade template for default screens
|
* Generate HTML from Blade template for default screens
|
||||||
*/
|
*/
|
||||||
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
|
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Map image type to template name
|
// Map image type to template name
|
||||||
$templateName = match ($imageType) {
|
$templateName = match ($imageType) {
|
||||||
'setup-logo' => 'default-screens.setup',
|
'setup-logo' => 'default-screens.setup',
|
||||||
'sleep' => 'default-screens.sleep',
|
'sleep' => 'default-screens.sleep',
|
||||||
|
'error' => 'default-screens.error',
|
||||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -424,14 +520,22 @@ class ImageGenerationService
|
||||||
$scaleLevel = $device->scaleLevel();
|
$scaleLevel = $device->scaleLevel();
|
||||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||||
|
|
||||||
// Render the Blade template
|
// Build view data
|
||||||
return view($templateName, [
|
$viewData = [
|
||||||
'noBleed' => false,
|
'noBleed' => false,
|
||||||
'darkMode' => $darkMode,
|
'darkMode' => $darkMode,
|
||||||
'deviceVariant' => $deviceVariant,
|
'deviceVariant' => $deviceVariant,
|
||||||
'deviceOrientation' => $deviceOrientation,
|
'deviceOrientation' => $deviceOrientation,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
])->render();
|
];
|
||||||
|
|
||||||
|
// Add plugin name for error screens
|
||||||
|
if ($imageType === 'error' && $pluginName !== null) {
|
||||||
|
$viewData['pluginName'] = $pluginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Blade template
|
||||||
|
return view($templateName, $viewData)->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,34 @@ use ZipArchive;
|
||||||
|
|
||||||
class PluginImportService
|
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
|
* Import a plugin from a ZIP file
|
||||||
*
|
*
|
||||||
|
|
@ -47,32 +75,55 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
if (! $filePaths['settingsYamlPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
|
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
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Read full.liquid content
|
// Determine which template file to use and read its content
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
$templatePath = null;
|
||||||
|
|
||||||
// Prepend shared.liquid content if available
|
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file ends with .liquid to set markup language
|
|
||||||
$markupLanguage = 'blade';
|
$markupLanguage = 'blade';
|
||||||
if (pathinfo((string) $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';
|
$markupLanguage = 'liquid';
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
$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
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -80,6 +131,9 @@ class PluginImportService
|
||||||
$settings['custom_fields'] = [];
|
$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
|
// Create configuration template with the custom fields
|
||||||
$configurationTemplate = [
|
$configurationTemplate = [
|
||||||
'custom_fields' => $settings['custom_fields'],
|
'custom_fields' => $settings['custom_fields'],
|
||||||
|
|
@ -141,11 +195,12 @@ class PluginImportService
|
||||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
* @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 $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
* @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
|
* @return Plugin The created plugin instance
|
||||||
*
|
*
|
||||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||||
*/
|
*/
|
||||||
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
|
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
|
||||||
{
|
{
|
||||||
// Download the ZIP file
|
// Download the ZIP file
|
||||||
$response = Http::timeout(60)->get($zipUrl);
|
$response = Http::timeout(60)->get($zipUrl);
|
||||||
|
|
@ -173,32 +228,55 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
if (! $filePaths['settingsYamlPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
|
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
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Read full.liquid content
|
// Determine which template file to use and read its content
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
$templatePath = null;
|
||||||
|
|
||||||
// Prepend shared.liquid content if available
|
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the file ends with .liquid to set markup language
|
|
||||||
$markupLanguage = 'blade';
|
$markupLanguage = 'blade';
|
||||||
if (pathinfo((string) $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';
|
$markupLanguage = 'liquid';
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
$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
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -206,22 +284,34 @@ class PluginImportService
|
||||||
$settings['custom_fields'] = [];
|
$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
|
// Create configuration template with the custom fields
|
||||||
$configurationTemplate = [
|
$configurationTemplate = [
|
||||||
'custom_fields' => $settings['custom_fields'],
|
'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();
|
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||||
|
|
||||||
// Create a new plugin
|
// Create a new plugin
|
||||||
$plugin = Plugin::updateOrCreate(
|
$plugin = Plugin::updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
'trmnlp_id' => $trmnlpId,
|
||||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||||
'polling_url' => $settings['polling_url'] ?? null,
|
'polling_url' => $settings['polling_url'] ?? null,
|
||||||
|
|
@ -266,6 +356,7 @@ class PluginImportService
|
||||||
$settingsYamlPath = null;
|
$settingsYamlPath = null;
|
||||||
$fullLiquidPath = null;
|
$fullLiquidPath = null;
|
||||||
$sharedLiquidPath = null;
|
$sharedLiquidPath = null;
|
||||||
|
$sharedBladePath = null;
|
||||||
|
|
||||||
// If zipEntryPath is specified, look for files in that specific directory first
|
// If zipEntryPath is specified, look for files in that specific directory first
|
||||||
if ($zipEntryPath) {
|
if ($zipEntryPath) {
|
||||||
|
|
@ -283,6 +374,8 @@ class PluginImportService
|
||||||
|
|
||||||
if (File::exists($targetDir.'/shared.liquid')) {
|
if (File::exists($targetDir.'/shared.liquid')) {
|
||||||
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
||||||
|
$sharedBladePath = $targetDir.'/shared.blade.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,15 +391,18 @@ class PluginImportService
|
||||||
|
|
||||||
if (File::exists($targetDir.'/src/shared.liquid')) {
|
if (File::exists($targetDir.'/src/shared.liquid')) {
|
||||||
$sharedLiquidPath = $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 we found the required files in the target directory, return them
|
||||||
if ($settingsYamlPath && $fullLiquidPath) {
|
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||||
return [
|
return [
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
|
'sharedBladePath' => $sharedBladePath,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -323,9 +419,11 @@ class PluginImportService
|
||||||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
$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')) {
|
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||||
|
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Search for the files in the extracted directory structure
|
// Search for the files in the extracted directory structure
|
||||||
|
|
@ -342,17 +440,24 @@ class PluginImportService
|
||||||
$fullLiquidPath = $filepath;
|
$fullLiquidPath = $filepath;
|
||||||
} elseif ($filename === 'shared.liquid') {
|
} elseif ($filename === 'shared.liquid') {
|
||||||
$sharedLiquidPath = $filepath;
|
$sharedLiquidPath = $filepath;
|
||||||
|
} elseif ($filename === 'shared.blade.php') {
|
||||||
|
$sharedBladePath = $filepath;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If we found both required files, break the loop
|
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
||||||
if ($settingsYamlPath && $fullLiquidPath) {
|
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
||||||
break;
|
$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,
|
// 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
|
// 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
|
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||||
$srcDir = dirname((string) $settingsYamlPath);
|
$srcDir = dirname((string) $settingsYamlPath);
|
||||||
|
|
||||||
|
|
@ -363,17 +468,25 @@ class PluginImportService
|
||||||
|
|
||||||
// Copy the files to the src directory
|
// Copy the files to the src directory
|
||||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
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) {
|
if ($sharedLiquidPath) {
|
||||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||||
$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
|
// Update the paths
|
||||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||||
$fullLiquidPath = $newSrcDir.'/full.liquid';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -382,9 +495,53 @@ class PluginImportService
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'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
|
* Validate that template and context are within command-line argument limits
|
||||||
*
|
*
|
||||||
|
|
|
||||||
15
boost.json
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"agents": [
|
|
||||||
"claude_code",
|
|
||||||
"copilot",
|
|
||||||
"cursor",
|
|
||||||
"phpstorm"
|
|
||||||
],
|
|
||||||
"editors": [
|
|
||||||
"claude_code",
|
|
||||||
"cursor",
|
|
||||||
"phpstorm",
|
|
||||||
"vscode"
|
|
||||||
],
|
|
||||||
"guidelines": []
|
|
||||||
}
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"trmnl",
|
"trmnl",
|
||||||
"trmnl-server",
|
"trmnl-server",
|
||||||
|
"trmnl-byos",
|
||||||
"laravel"
|
"laravel"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -14,8 +15,8 @@
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
"ext-simplexml": "*",
|
"ext-simplexml": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||||
"bnussbau/trmnl-pipeline-php": "^0.4.0",
|
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||||
"keepsuit/laravel-liquid": "^0.5.2",
|
"keepsuit/laravel-liquid": "^0.5.2",
|
||||||
"laravel/framework": "^12.1",
|
"laravel/framework": "^12.1",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
|
|
@ -23,7 +24,9 @@
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.0",
|
"livewire/flux": "^2.0",
|
||||||
"livewire/volt": "^1.7",
|
"livewire/volt": "^1.7",
|
||||||
|
"om/icalparser": "^3.2",
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
|
"stevebauman/purify": "^6.3",
|
||||||
"symfony/yaml": "^7.3",
|
"symfony/yaml": "^7.3",
|
||||||
"wnx/sidecar-browsershot": "^2.6"
|
"wnx/sidecar-browsershot": "^2.6"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
1547
composer.lock
generated
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,
|
'icon_url' => null,
|
||||||
'flux_icon_name' => null,
|
'flux_icon_name' => null,
|
||||||
'author_name' => $this->faker->name(),
|
'author_name' => $this->faker->name(),
|
||||||
|
'plugin_type' => 'recipe',
|
||||||
'created_at' => Carbon::now(),
|
'created_at' => Carbon::now(),
|
||||||
'updated_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
|
public function down(): void
|
||||||
{
|
{
|
||||||
Schema::table('users', function (Blueprint $table) {
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['oidc_sub']);
|
||||||
$table->dropColumn('oidc_sub');
|
$table->dropColumn('oidc_sub');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,60 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
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 = DB::table('plugins')
|
||||||
|
->select('user_id', 'trmnlp_id', DB::raw('COUNT(*) as count'))
|
||||||
|
->whereNotNull('trmnlp_id')
|
||||||
|
->groupBy('user_id', 'trmnlp_id')
|
||||||
|
->having('count', '>', 1)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// For each duplicate combination, keep the first one (by id) and set others to null
|
||||||
|
foreach ($duplicates as $duplicate) {
|
||||||
|
$plugins = DB::table('plugins')
|
||||||
|
->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;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('plugins')
|
||||||
|
->where('id', $plugin->id)
|
||||||
|
->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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -144,5 +144,42 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
'flux_icon_name' => 'flower',
|
'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',
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1009
package-lock.json
generated
|
|
@ -24,7 +24,7 @@
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0",
|
"laravel-vite-plugin": "^2.0",
|
||||||
"puppeteer": "24.17.0",
|
"puppeteer": "24.30.0",
|
||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
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"
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,10 @@
|
||||||
@apply !mb-0 !leading-tight;
|
@apply !mb-0 !leading-tight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-flux-description] a {
|
||||||
|
@apply text-accent underline hover:opacity-80;
|
||||||
|
}
|
||||||
|
|
||||||
input:focus[data-flux-control],
|
input:focus[data-flux-control],
|
||||||
textarea:focus[data-flux-control],
|
textarea:focus[data-flux-control],
|
||||||
select:focus[data-flux-control] {
|
select:focus[data-flux-control] {
|
||||||
|
|
@ -68,3 +72,39 @@ select:focus[data-flux-control] {
|
||||||
/* \[:where(&)\]:size-4 {
|
/* \[:where(&)\]:size-4 {
|
||||||
@apply 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||||
import { ViewPlugin } from '@codemirror/view';
|
import { ViewPlugin } from '@codemirror/view';
|
||||||
import { indentWithTab } from '@codemirror/commands';
|
import { indentWithTab, selectAll } from '@codemirror/commands';
|
||||||
import { foldGutter, foldKeymap } from '@codemirror/language';
|
import { foldGutter, foldKeymap } from '@codemirror/language';
|
||||||
import { history, historyKeymap } from '@codemirror/commands';
|
import { history, historyKeymap } from '@codemirror/commands';
|
||||||
|
import { searchKeymap } from '@codemirror/search';
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
|
|
@ -154,7 +155,16 @@ export function createCodeMirror(element, options = {}) {
|
||||||
createResizePlugin(),
|
createResizePlugin(),
|
||||||
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
||||||
...themeSupport,
|
...themeSupport,
|
||||||
keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
|
keymap.of([
|
||||||
|
indentWithTab,
|
||||||
|
...foldKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
{
|
||||||
|
key: 'Mod-a',
|
||||||
|
run: selectAll,
|
||||||
|
},
|
||||||
|
]),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<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 class="px-10 py-8">{{ $slot }}</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Attributes\Lazy;
|
|
||||||
use Livewire\Volt\Component;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component {
|
class extends Component
|
||||||
|
{
|
||||||
public array $catalogPlugins = [];
|
public array $catalogPlugins = [];
|
||||||
|
|
||||||
public string $installingPlugin = '';
|
public string $installingPlugin = '';
|
||||||
|
|
||||||
|
public string $previewingPlugin = '';
|
||||||
|
|
||||||
|
public array $previewData = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadCatalogPlugins();
|
$this->loadCatalogPlugins();
|
||||||
|
|
@ -49,7 +55,7 @@ class extends Component {
|
||||||
return collect($catalog)
|
return collect($catalog)
|
||||||
->filter(function ($plugin) use ($currentVersion) {
|
->filter(function ($plugin) use ($currentVersion) {
|
||||||
// Check if Laravel compatibility is true
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,8 +85,9 @@ class extends Component {
|
||||||
})
|
})
|
||||||
->sortBy('name')
|
->sortBy('name')
|
||||||
->toArray();
|
->toArray();
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Failed to load catalog from URL: ' . $e->getMessage());
|
Log::error('Failed to load catalog from URL: '.$e->getMessage());
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -92,8 +99,9 @@ class extends Component {
|
||||||
|
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$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.');
|
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,24 +113,45 @@ class extends Component {
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
$plugin['zip_entry_path'] ?? null,
|
$plugin['zip_entry_path'] ?? null,
|
||||||
null,
|
null,
|
||||||
$plugin['logo_url'] ?? null
|
$plugin['logo_url'] ?? null,
|
||||||
|
allowDuplicate: true
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-catalog')->close();
|
Flux::modal('import-from-catalog')->close();
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->addError('installation', 'Error installing plugin: ' . $e->getMessage());
|
$this->addError('installation', 'Error installing plugin: '.$e->getMessage());
|
||||||
} finally {
|
} finally {
|
||||||
$this->installingPlugin = '';
|
$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">
|
<div class="space-y-4">
|
||||||
@if(empty($catalogPlugins))
|
@if(empty($catalogPlugins))
|
||||||
<div class="text-center py-8">
|
<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:heading class="mt-2">No plugins available</flux:heading>
|
||||||
<flux:subheading>Catalog is empty</flux:subheading>
|
<flux:subheading>Catalog is empty</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -133,30 +162,30 @@ class extends Component {
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
@foreach($catalogPlugins as $plugin)
|
@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">
|
<div class="flex items-start space-x-4">
|
||||||
@if($plugin['logo_url'])
|
@if($plugin['logo_url'])
|
||||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" 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
|
@else
|
||||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<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-gray-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<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'])
|
@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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($plugin['license'])
|
@if($plugin['license'])
|
||||||
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||||
@endif
|
@endif
|
||||||
@if($plugin['repo_url'])
|
@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" />
|
<flux:icon name="github" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -164,7 +193,7 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($plugin['description'])
|
@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
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -174,6 +203,19 @@ class extends Component {
|
||||||
Install
|
Install
|
||||||
</flux:button>
|
</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'])
|
@if($plugin['learn_more_url'])
|
||||||
<flux:button
|
<flux:button
|
||||||
href="{{ $plugin['learn_more_url'] }}"
|
href="{{ $plugin['learn_more_url'] }}"
|
||||||
|
|
@ -189,4 +231,38 @@ class extends Component {
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,30 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Livewire\Attributes\Lazy;
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Volt\Component;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Livewire\Attributes\Lazy;
|
||||||
use App\Services\PluginImportService;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component {
|
class extends Component
|
||||||
|
{
|
||||||
public array $recipes = [];
|
public array $recipes = [];
|
||||||
|
|
||||||
|
public int $page = 1;
|
||||||
|
|
||||||
|
public bool $hasMore = false;
|
||||||
|
|
||||||
public string $search = '';
|
public string $search = '';
|
||||||
|
|
||||||
public bool $isSearching = false;
|
public bool $isSearching = false;
|
||||||
|
|
||||||
|
public string $previewingRecipe = '';
|
||||||
|
|
||||||
|
public array $previewData = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->loadNewest();
|
$this->loadNewest();
|
||||||
|
|
@ -37,22 +47,36 @@ class extends Component {
|
||||||
private function loadNewest(): void
|
private function loadNewest(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () {
|
$cacheKey = 'trmnl_recipes_newest_page_'.$this->page;
|
||||||
|
$response = Cache::remember($cacheKey, 43200, function () {
|
||||||
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
$response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
|
'page' => $this->page,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new \RuntimeException('Failed to fetch TRMNL recipes');
|
throw new RuntimeException('Failed to fetch TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $response->json();
|
return $response->json();
|
||||||
$data = $json['data'] ?? [];
|
|
||||||
return $this->mapRecipes($data);
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog load error: ' . $e->getMessage());
|
$data = $response['data'] ?? [];
|
||||||
$this->recipes = [];
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,38 +84,65 @@ class extends Component {
|
||||||
{
|
{
|
||||||
$this->isSearching = true;
|
$this->isSearching = true;
|
||||||
try {
|
try {
|
||||||
$cacheKey = 'trmnl_recipes_search_' . md5($term);
|
$cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page;
|
||||||
$this->recipes = Cache::remember($cacheKey, 300, function () use ($term) {
|
$response = Cache::remember($cacheKey, 300, function () use ($term) {
|
||||||
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
$response = Http::get('https://usetrmnl.com/recipes.json', [
|
||||||
'search' => $term,
|
'search' => $term,
|
||||||
'sort-by' => 'newest',
|
'sort-by' => 'newest',
|
||||||
|
'page' => $this->page,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!$response->successful()) {
|
if (! $response->successful()) {
|
||||||
throw new \RuntimeException('Failed to search TRMNL recipes');
|
throw new RuntimeException('Failed to search TRMNL recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
$json = $response->json();
|
return $response->json();
|
||||||
$data = $json['data'] ?? [];
|
|
||||||
return $this->mapRecipes($data);
|
|
||||||
});
|
});
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('TRMNL catalog search error: ' . $e->getMessage());
|
$data = $response['data'] ?? [];
|
||||||
$this->recipes = [];
|
$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 {
|
} finally {
|
||||||
$this->isSearching = false;
|
$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
|
public function updatedSearch(): void
|
||||||
{
|
{
|
||||||
$term = trim($this->search);
|
$this->page = 1;
|
||||||
|
$term = mb_trim($this->search);
|
||||||
if ($term === '') {
|
if ($term === '') {
|
||||||
$this->loadNewest();
|
$this->loadNewest();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (strlen($term) < 2) {
|
if (mb_strlen($term) < 2) {
|
||||||
// Require at least 2 chars to avoid noisy calls
|
// Require at least 2 chars to avoid noisy calls
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -113,43 +164,85 @@ class extends Component {
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
null,
|
null,
|
||||||
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
|
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
|
||||||
$recipe['icon_url'] ?? null
|
$recipe['icon_url'] ?? null,
|
||||||
|
allowDuplicate: true
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->dispatch('plugin-installed');
|
$this->dispatch('plugin-installed');
|
||||||
Flux::modal('import-from-trmnl-catalog')->close();
|
Flux::modal('import-from-trmnl-catalog')->close();
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Plugin installation failed: ' . $e->getMessage());
|
Log::error('Plugin installation failed: '.$e->getMessage());
|
||||||
$this->addError('installation', 'Error installing plugin: ' . $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
|
* @param array<int, array<string, mixed>> $items
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function mapRecipes(array $items): array
|
private function mapRecipes(array $items): array
|
||||||
{
|
{
|
||||||
return collect($items)
|
return collect($items)
|
||||||
->map(function (array $item) {
|
->map(fn (array $item) => $this->mapRecipe($item))
|
||||||
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,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->toArray();
|
->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="space-y-4">
|
||||||
|
|
@ -161,7 +254,7 @@ class extends Component {
|
||||||
icon="magnifying-glass"
|
icon="magnifying-glass"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<flux:badge color="gray">Newest</flux:badge>
|
<flux:badge color="zinc">Newest</flux:badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@error('installation')
|
@error('installation')
|
||||||
|
|
@ -170,35 +263,36 @@ class extends Component {
|
||||||
|
|
||||||
@if(empty($recipes))
|
@if(empty($recipes))
|
||||||
<div class="text-center py-8">
|
<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 recipes found</flux:heading>
|
<flux:heading class="mt-2">No recipes found</flux:heading>
|
||||||
<flux:subheading>Try a different search term</flux:subheading>
|
<flux:subheading>Try a different search term</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
@foreach($recipes as $recipe)
|
@foreach($recipes as $recipe)
|
||||||
<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="recipe-{{ $recipe['id'] }}" class="rounded-xl dark:bg-white/10 border border-zinc-200 dark:border-white/10 shadow-xs">
|
||||||
<div class="flex items-start space-x-4">
|
<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'])
|
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url'])
|
||||||
@if($thumb)
|
@if($thumb)
|
||||||
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $thumb }}" loading="lazy" alt="{{ $recipe['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<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-gray-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $recipe['name'] }}</h3>
|
<flux:heading size="lg">{{ $recipe['name'] }}</flux:heading>
|
||||||
@if(data_get($recipe, 'stats.installs'))
|
@if(data_get($recipe, 'stats.installs'))
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}</p>
|
<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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($recipe['detail_url'])
|
@if($recipe['detail_url'])
|
||||||
<a href="{{ $recipe['detail_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<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" />
|
<flux:icon name="arrow-top-right-on-square" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -206,7 +300,7 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($recipe['author_bio'])
|
@if($recipe['author_bio'])
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $recipe['author_bio'] }}</p>
|
<flux:text class="mt-2" size="sm">{{ $recipe['author_bio'] }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -218,19 +312,96 @@ class extends Component {
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($recipe['detail_url'])
|
@if($recipe['id'] && ($recipe['screenshot_url'] ?? null))
|
||||||
<flux:button
|
<flux:modal.trigger name="trmnl-catalog-preview">
|
||||||
href="{{ $recipe['detail_url'] }}"
|
<flux:button
|
||||||
target="_blank"
|
wire:click="previewRecipe('{{ $recipe['id'] }}')"
|
||||||
variant="subtle">
|
variant="subtle"
|
||||||
View on TRMNL
|
icon="eye">
|
||||||
</flux:button>
|
Preview
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</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
|
@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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ new class extends Component {
|
||||||
@if($devices->isEmpty())
|
@if($devices->isEmpty())
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div
|
<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">
|
<div class="px-10 py-8">
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">Add your first device</h1>
|
<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"
|
<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)
|
@foreach($devices as $device)
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div
|
<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">
|
<div class="px-10 py-8">
|
||||||
@php
|
@php
|
||||||
$current_image_uuid =$device->current_screen_image;
|
$current_image_uuid =$device->current_screen_image;
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,43 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\DeviceModel;
|
use App\Models\DeviceModel;
|
||||||
|
use App\Models\DevicePalette;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component
|
||||||
|
{
|
||||||
public $deviceModels;
|
public $deviceModels;
|
||||||
|
|
||||||
|
public $devicePalettes;
|
||||||
|
|
||||||
public $name;
|
public $name;
|
||||||
|
|
||||||
public $label;
|
public $label;
|
||||||
|
|
||||||
public $description;
|
public $description;
|
||||||
|
|
||||||
public $width;
|
public $width;
|
||||||
|
|
||||||
public $height;
|
public $height;
|
||||||
|
|
||||||
public $colors;
|
public $colors;
|
||||||
|
|
||||||
public $bit_depth;
|
public $bit_depth;
|
||||||
|
|
||||||
public $scale_factor = 1.0;
|
public $scale_factor = 1.0;
|
||||||
|
|
||||||
public $rotation = 0;
|
public $rotation = 0;
|
||||||
|
|
||||||
public $mime_type = 'image/png';
|
public $mime_type = 'image/png';
|
||||||
|
|
||||||
public $offset_x = 0;
|
public $offset_x = 0;
|
||||||
|
|
||||||
public $offset_y = 0;
|
public $offset_y = 0;
|
||||||
|
|
||||||
public $published_at;
|
public $published_at;
|
||||||
|
|
||||||
|
public $palette_id;
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
'name' => 'required|string|max:255|unique:device_models,name',
|
'name' => 'required|string|max:255|unique:device_models,name',
|
||||||
'label' => 'required|string|max:255',
|
'label' => 'required|string|max:255',
|
||||||
|
|
@ -40,62 +57,58 @@ new class extends Component {
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->deviceModels = DeviceModel::all();
|
$this->deviceModels = DeviceModel::all();
|
||||||
|
$this->devicePalettes = DevicePalette::all();
|
||||||
|
|
||||||
return view('livewire.device-models.index');
|
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 $editingDeviceModelId;
|
||||||
|
|
||||||
public function editDeviceModel(DeviceModel $deviceModel): void
|
public $viewingDeviceModelId;
|
||||||
|
|
||||||
|
public function openDeviceModelModal(?string $deviceModelId = null, bool $viewOnly = false): void
|
||||||
{
|
{
|
||||||
$this->editingDeviceModelId = $deviceModel->id;
|
if ($deviceModelId) {
|
||||||
$this->name = $deviceModel->name;
|
$deviceModel = DeviceModel::findOrFail($deviceModelId);
|
||||||
$this->label = $deviceModel->label;
|
|
||||||
$this->description = $deviceModel->description;
|
if ($viewOnly) {
|
||||||
$this->width = $deviceModel->width;
|
$this->viewingDeviceModelId = $deviceModel->id;
|
||||||
$this->height = $deviceModel->height;
|
$this->editingDeviceModelId = null;
|
||||||
$this->colors = $deviceModel->colors;
|
} else {
|
||||||
$this->bit_depth = $deviceModel->bit_depth;
|
$this->editingDeviceModelId = $deviceModel->id;
|
||||||
$this->scale_factor = $deviceModel->scale_factor;
|
$this->viewingDeviceModelId = null;
|
||||||
$this->rotation = $deviceModel->rotation;
|
}
|
||||||
$this->mime_type = $deviceModel->mime_type;
|
|
||||||
$this->offset_x = $deviceModel->offset_x;
|
$this->name = $deviceModel->name;
|
||||||
$this->offset_y = $deviceModel->offset_y;
|
$this->label = $deviceModel->label;
|
||||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
$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);
|
$rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
$this->validate([
|
|
||||||
'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id,
|
|
||||||
'label' => 'required|string|max:255',
|
'label' => 'required|string|max:255',
|
||||||
'description' => 'required|string',
|
'description' => 'required|string',
|
||||||
'width' => 'required|integer|min:1',
|
'width' => 'required|integer|min:1',
|
||||||
|
|
@ -108,38 +121,96 @@ new class extends Component {
|
||||||
'offset_x' => 'required|integer',
|
'offset_x' => 'required|integer',
|
||||||
'offset_y' => 'required|integer',
|
'offset_y' => 'required|integer',
|
||||||
'published_at' => 'nullable|date',
|
'published_at' => 'nullable|date',
|
||||||
]);
|
'palette_id' => 'nullable|exists:device_palettes,id',
|
||||||
|
];
|
||||||
|
|
||||||
$deviceModel->update([
|
if ($this->editingDeviceModelId) {
|
||||||
'name' => $this->name,
|
$rules['name'] = 'required|string|max:255|unique:device_models,name,'.$this->editingDeviceModelId;
|
||||||
'label' => $this->label,
|
} else {
|
||||||
'description' => $this->description,
|
$rules['name'] = 'required|string|max:255|unique:device_models,name';
|
||||||
'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', 'editingDeviceModelId']);
|
$this->validate($rules);
|
||||||
\Flux::modal('edit-device-model-' . $deviceModel->id)->close();
|
|
||||||
|
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();
|
$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();
|
$deviceModel->delete();
|
||||||
|
|
||||||
$this->deviceModels = DeviceModel::all();
|
$this->deviceModels = DeviceModel::all();
|
||||||
session()->flash('message', 'Device model deleted successfully.');
|
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="py-12">
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
|
<div class="flex items-center space-x-2">
|
||||||
{{-- <flux:modal.trigger name="create-device-model">--}}
|
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
|
||||||
{{-- <flux:button icon="plus" variant="primary">Add Device Model</flux:button>--}}
|
<flux:dropdown>
|
||||||
{{-- </flux:modal.trigger>--}}
|
<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>
|
</div>
|
||||||
@if (session()->has('message'))
|
@if (session()->has('message'))
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
|
|
@ -164,157 +244,104 @@ new class extends Component {
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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 class="space-y-6">
|
||||||
<div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form wire:submit="createDeviceModel">
|
<form wire:submit="saveDeviceModel">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
<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>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full"
|
<flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full"
|
||||||
type="text"
|
type="text"
|
||||||
name="label"/>
|
name="label" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input label="Description" wire:model="description" id="description"
|
<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>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<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"
|
<flux:input label="Width" wire:model="width" id="width" class="block mt-1 w-full"
|
||||||
type="number"
|
type="number"
|
||||||
name="width"/>
|
name="width" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
<flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full"
|
<flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full"
|
||||||
type="number"
|
type="number"
|
||||||
name="height"/>
|
name="height" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<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"
|
<flux:input label="Colors" wire:model="colors" id="colors" class="block mt-1 w-full"
|
||||||
type="number"
|
type="number"
|
||||||
name="colors"/>
|
name="colors" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
<flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth"
|
<flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth"
|
||||||
class="block mt-1 w-full" type="number"
|
class="block mt-1 w-full" type="number"
|
||||||
name="bit_depth"/>
|
name="bit_depth" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
<flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor"
|
<flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor"
|
||||||
class="block mt-1 w-full" type="number"
|
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"
|
<flux:input label="Rotation" wire:model="rotation" id="rotation" class="block mt-1 w-full"
|
||||||
type="number"
|
type="number"
|
||||||
name="rotation"/>
|
name="rotation" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:input label="MIME Type" wire:model="mime_type" id="mime_type"
|
<flux:select label="MIME Type" wire:model="mime_type" id="mime_type" name="mime_type" :disabled="(bool) $viewingDeviceModelId">
|
||||||
class="block mt-1 w-full" type="text"
|
<flux:select.option>image/png</flux:select.option>
|
||||||
name="mime_type"/>
|
<flux:select.option>image/bmp</flux:select.option>
|
||||||
|
</flux:select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
<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"
|
<flux:input label="Offset X" wire:model="offset_x" id="offset_x" class="block mt-1 w-full"
|
||||||
type="number"
|
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"
|
<flux:input label="Offset Y" wire:model="offset_y" id="offset_y" class="block mt-1 w-full"
|
||||||
type="number"
|
type="number"
|
||||||
name="offset_y"/>
|
name="offset_y" :disabled="(bool) $viewingDeviceModelId"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex">
|
<div class="mb-4">
|
||||||
<flux:spacer/>
|
<flux:select label="Color Palette" wire:model="palette_id" id="palette_id" name="palette_id" :disabled="(bool) $viewingDeviceModelId">
|
||||||
<flux:button type="submit" variant="primary">Create Device Model</flux:button>
|
<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>
|
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</flux:modal>
|
</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
|
<table
|
||||||
class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800"
|
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>
|
data-flux-table>
|
||||||
|
|
@ -369,14 +396,25 @@ new class extends Component {
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<flux:button.group>
|
<flux:button.group>
|
||||||
<flux:modal.trigger name="edit-device-model-{{ $deviceModel->id }}">
|
@if ($deviceModel->source === 'api')
|
||||||
<flux:button wire:click="editDeviceModel({{ $deviceModel->id }})" icon="pencil"
|
<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">
|
iconVariant="outline">
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</flux:modal.trigger>
|
@else
|
||||||
<flux:button wire:click="deleteDeviceModel({{ $deviceModel->id }})" icon="trash"
|
<flux:modal.trigger name="device-model-modal">
|
||||||
iconVariant="outline">
|
<flux:button wire:click="openDeviceModelModal('{{ $deviceModel->id }}')" icon="pencil"
|
||||||
</flux:button>
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
<flux:button wire:click="deleteDeviceModel('{{ $deviceModel->id }}')" icon="trash"
|
||||||
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
</flux:button.group>
|
</flux:button.group>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
384
resources/views/livewire/device-palettes/index.blade.php
Normal file
|
|
@ -0,0 +1,384 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DevicePalette;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component
|
||||||
|
{
|
||||||
|
public $devicePalettes;
|
||||||
|
|
||||||
|
public $name;
|
||||||
|
|
||||||
|
public $description;
|
||||||
|
|
||||||
|
public $grays = 2;
|
||||||
|
|
||||||
|
public $colors = [];
|
||||||
|
|
||||||
|
public $framework_class = '';
|
||||||
|
|
||||||
|
public $colorInput = '';
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'name' => 'required|string|max:255|unique:device_palettes,name',
|
||||||
|
'description' => 'nullable|string|max:255',
|
||||||
|
'grays' => 'required|integer|min:1|max:256',
|
||||||
|
'colors' => 'nullable|array',
|
||||||
|
'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
'framework_class' => 'nullable|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->devicePalettes = DevicePalette::all();
|
||||||
|
|
||||||
|
return view('livewire.device-palettes.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addColor(): void
|
||||||
|
{
|
||||||
|
$this->validate(['colorInput' => 'required|string|regex:/^#[0-9A-Fa-f]{6}$/'], [
|
||||||
|
'colorInput.regex' => 'Color must be a valid hex color (e.g., #FF0000)',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! in_array($this->colorInput, $this->colors)) {
|
||||||
|
$this->colors[] = $this->colorInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->colorInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeColor(int $index): void
|
||||||
|
{
|
||||||
|
unset($this->colors[$index]);
|
||||||
|
$this->colors = array_values($this->colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public $editingDevicePaletteId;
|
||||||
|
|
||||||
|
public $viewingDevicePaletteId;
|
||||||
|
|
||||||
|
public function openDevicePaletteModal(?string $devicePaletteId = null, bool $viewOnly = false): void
|
||||||
|
{
|
||||||
|
if ($devicePaletteId) {
|
||||||
|
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
|
||||||
|
|
||||||
|
if ($viewOnly) {
|
||||||
|
$this->viewingDevicePaletteId = $devicePalette->id;
|
||||||
|
$this->editingDevicePaletteId = null;
|
||||||
|
} else {
|
||||||
|
$this->editingDevicePaletteId = $devicePalette->id;
|
||||||
|
$this->viewingDevicePaletteId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->name = $devicePalette->name;
|
||||||
|
$this->description = $devicePalette->description;
|
||||||
|
$this->grays = $devicePalette->grays;
|
||||||
|
|
||||||
|
// Ensure colors is always an array and properly decoded
|
||||||
|
// The model cast should handle JSON decoding, but we'll be explicit
|
||||||
|
$colors = $devicePalette->getAttribute('colors');
|
||||||
|
|
||||||
|
if ($colors === null) {
|
||||||
|
$this->colors = [];
|
||||||
|
} elseif (is_string($colors)) {
|
||||||
|
$decoded = json_decode($colors, true);
|
||||||
|
$this->colors = is_array($decoded) ? array_values($decoded) : [];
|
||||||
|
} elseif (is_array($colors)) {
|
||||||
|
$this->colors = array_values($colors); // Re-index array
|
||||||
|
} else {
|
||||||
|
$this->colors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->framework_class = $devicePalette->framework_class;
|
||||||
|
} else {
|
||||||
|
$this->editingDevicePaletteId = null;
|
||||||
|
$this->viewingDevicePaletteId = null;
|
||||||
|
$this->reset(['name', 'description', 'grays', 'colors', 'framework_class']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->colorInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveDevicePalette(): void
|
||||||
|
{
|
||||||
|
$rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'description' => 'nullable|string|max:255',
|
||||||
|
'grays' => 'required|integer|min:1|max:256',
|
||||||
|
'colors' => 'nullable|array',
|
||||||
|
'colors.*' => 'string|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
'framework_class' => 'nullable|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->editingDevicePaletteId) {
|
||||||
|
$rules['name'] = 'required|string|max:255|unique:device_palettes,name,'.$this->editingDevicePaletteId;
|
||||||
|
} else {
|
||||||
|
$rules['name'] = 'required|string|max:255|unique:device_palettes,name';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validate($rules);
|
||||||
|
|
||||||
|
if ($this->editingDevicePaletteId) {
|
||||||
|
$devicePalette = DevicePalette::findOrFail($this->editingDevicePaletteId);
|
||||||
|
$devicePalette->update([
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'grays' => $this->grays,
|
||||||
|
'colors' => ! empty($this->colors) ? $this->colors : null,
|
||||||
|
'framework_class' => $this->framework_class,
|
||||||
|
]);
|
||||||
|
$message = 'Device palette updated successfully.';
|
||||||
|
} else {
|
||||||
|
DevicePalette::create([
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'grays' => $this->grays,
|
||||||
|
'colors' => ! empty($this->colors) ? $this->colors : null,
|
||||||
|
'framework_class' => $this->framework_class,
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
$message = 'Device palette created successfully.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset(['name', 'description', 'grays', 'colors', 'framework_class', 'colorInput', 'editingDevicePaletteId', 'viewingDevicePaletteId']);
|
||||||
|
Flux::modal('device-palette-modal')->close();
|
||||||
|
|
||||||
|
$this->devicePalettes = DevicePalette::all();
|
||||||
|
session()->flash('message', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteDevicePalette(string $devicePaletteId): void
|
||||||
|
{
|
||||||
|
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
|
||||||
|
$devicePalette->delete();
|
||||||
|
|
||||||
|
$this->devicePalettes = DevicePalette::all();
|
||||||
|
session()->flash('message', 'Device palette deleted successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function duplicateDevicePalette(string $devicePaletteId): void
|
||||||
|
{
|
||||||
|
$devicePalette = DevicePalette::findOrFail($devicePaletteId);
|
||||||
|
|
||||||
|
$this->editingDevicePaletteId = null;
|
||||||
|
$this->viewingDevicePaletteId = null;
|
||||||
|
$this->name = $devicePalette->name.' (Copy)';
|
||||||
|
$this->description = $devicePalette->description;
|
||||||
|
$this->grays = $devicePalette->grays;
|
||||||
|
|
||||||
|
$colors = $devicePalette->getAttribute('colors');
|
||||||
|
if ($colors === null) {
|
||||||
|
$this->colors = [];
|
||||||
|
} elseif (is_string($colors)) {
|
||||||
|
$decoded = json_decode($colors, true);
|
||||||
|
$this->colors = is_array($decoded) ? array_values($decoded) : [];
|
||||||
|
} elseif (is_array($colors)) {
|
||||||
|
$this->colors = array_values($colors);
|
||||||
|
} else {
|
||||||
|
$this->colors = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->framework_class = $devicePalette->framework_class;
|
||||||
|
$this->colorInput = '';
|
||||||
|
|
||||||
|
$this->js('Flux.modal("device-palette-modal").show()');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<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">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Palettes</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-models.index') }}">Device Models</flux:menu.item>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</div>
|
||||||
|
<flux:modal.trigger name="device-palette-modal">
|
||||||
|
<flux:button wire:click="openDevicePaletteModal()" icon="plus" variant="primary">Add Device Palette</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</div>
|
||||||
|
@if (session()->has('message'))
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
|
||||||
|
<x-slot name="controls">
|
||||||
|
<flux:button icon="x-mark" variant="ghost"
|
||||||
|
x-on:click="$el.closest('[data-flux-callout]').remove()"/>
|
||||||
|
</x-slot>
|
||||||
|
</flux:callout>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<flux:modal name="device-palette-modal" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">
|
||||||
|
@if ($viewingDevicePaletteId)
|
||||||
|
View Device Palette
|
||||||
|
@elseif ($editingDevicePaletteId)
|
||||||
|
Edit Device Palette
|
||||||
|
@else
|
||||||
|
Add Device Palette
|
||||||
|
@endif
|
||||||
|
</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveDevicePalette">
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Name (Identifier)" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||||
|
name="name" autofocus :disabled="$viewingDevicePaletteId"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Description" wire:model="description" id="description" class="block mt-1 w-full" type="text"
|
||||||
|
name="description" :disabled="$viewingDevicePaletteId"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Grays" wire:model="grays" id="grays" class="block mt-1 w-full"
|
||||||
|
type="number"
|
||||||
|
name="grays" min="1" max="256" :disabled="$viewingDevicePaletteId"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Framework Class" wire:model="framework_class" id="framework_class"
|
||||||
|
class="block mt-1 w-full" type="text"
|
||||||
|
name="framework_class" :disabled="$viewingDevicePaletteId"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:label>Colors</flux:label>
|
||||||
|
@if (!$viewingDevicePaletteId)
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<flux:input wire:model="colorInput" placeholder="#FF0000" class="flex-1"/>
|
||||||
|
<flux:button type="button" wire:click="addColor" variant="ghost">Add</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@if (!empty($colors) && is_array($colors) && count($colors) > 0)
|
||||||
|
@foreach ($colors as $index => $color)
|
||||||
|
@if (!empty($color))
|
||||||
|
<div wire:key="color-{{ $editingDevicePaletteId ?? $viewingDevicePaletteId ?? 'new' }}-{{ $index }}-{{ $color }}" class="flex items-center gap-2 px-3 py-1 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||||
|
<div class="w-4 h-4 rounded border border-zinc-300 dark:border-zinc-600" style="background-color: {{ $color }}"></div>
|
||||||
|
<span class="text-sm">{{ $color }}</span>
|
||||||
|
@if (!$viewingDevicePaletteId)
|
||||||
|
<flux:button type="button" wire:click="removeColor({{ $index }})" icon="x-mark" variant="ghost" size="sm"></flux:button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@if (!$viewingDevicePaletteId)
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">Leave empty for grayscale-only palette</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!$viewingDevicePaletteId)
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary">{{ $editingDevicePaletteId ? 'Update' : 'Create' }} Device Palette</flux:button>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="button" wire:click="duplicateDevicePalette('{{ $viewingDevicePaletteId }}')" variant="primary">Duplicate</flux:button>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<thead data-flux-columns>
|
||||||
|
<tr>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column>
|
||||||
|
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Description</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column>
|
||||||
|
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Grays</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column>
|
||||||
|
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Colors</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column>
|
||||||
|
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Actions</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows>
|
||||||
|
@foreach ($devicePalettes as $devicePalette)
|
||||||
|
<tr data-flux-row>
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-zinc-800 dark:text-white">{{ $devicePalette->description ?? $devicePalette->name }}</div>
|
||||||
|
<div class="text-xs text-zinc-500">{{ $devicePalette->name }}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
{{ $devicePalette->grays }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||||
|
>
|
||||||
|
@if ($devicePalette->colors)
|
||||||
|
<div class="flex gap-1">
|
||||||
|
@foreach ($devicePalette->colors as $color)
|
||||||
|
<div class="w-4 h-4 rounded border border-zinc-300 dark:border-zinc-600" style="background-color: {{ $color }}"></div>
|
||||||
|
@endforeach
|
||||||
|
<span class="ml-2">({{ count($devicePalette->colors) }})</span>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<span class="text-zinc-400">Grayscale only</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<flux:button.group>
|
||||||
|
@if ($devicePalette->source === 'api')
|
||||||
|
<flux:modal.trigger name="device-palette-modal">
|
||||||
|
<flux:button wire:click="openDevicePaletteModal('{{ $devicePalette->id }}', true)" icon="eye"
|
||||||
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
<flux:button wire:click="duplicateDevicePalette('{{ $devicePalette->id }}')" icon="document-duplicate"
|
||||||
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
@else
|
||||||
|
<flux:modal.trigger name="device-palette-modal">
|
||||||
|
<flux:button wire:click="openDevicePaletteModal('{{ $devicePalette->id }}')" icon="pencil"
|
||||||
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
<flux:button wire:click="deleteDevicePalette('{{ $devicePalette->id }}')" icon="trash"
|
||||||
|
iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
@endif
|
||||||
|
</flux:button.group>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
@ -309,7 +309,7 @@ new class extends Component {
|
||||||
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10">
|
<div class="bg-muted flex flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<div
|
<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">
|
<div class="px-10 py-8">
|
||||||
@php
|
@php
|
||||||
$current_image_uuid =$device->current_screen_image;
|
$current_image_uuid =$device->current_screen_image;
|
||||||
|
|
@ -368,6 +368,10 @@ new class extends Component {
|
||||||
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item>
|
<flux:menu.item icon="arrow-up-circle">Update Firmware</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
<flux:menu.item icon="bars-3" href="{{ route('devices.logs', $device) }}" wire:navigate>Show Logs</flux:menu.item>
|
<flux:menu.item icon="bars-3" href="{{ route('devices.logs', $device) }}" wire:navigate>Show Logs</flux:menu.item>
|
||||||
|
<flux:modal.trigger name="mirror-url">
|
||||||
|
<flux:menu.item icon="link">Mirror URL</flux:menu.item>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
<flux:menu.separator/>
|
||||||
<flux:modal.trigger name="delete-device">
|
<flux:modal.trigger name="delete-device">
|
||||||
<flux:menu.item icon="trash" variant="danger">Delete Device</flux:menu.item>
|
<flux:menu.item icon="trash" variant="danger">Delete Device</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
@ -498,6 +502,26 @@ new class extends Component {
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
|
|
||||||
|
<flux:modal name="mirror-url" class="md:w-96">
|
||||||
|
@php
|
||||||
|
$mirrorUrl = url('/mirror/index.html') . '?mac_address=' . urlencode($device->mac_address) . '&api_key=' . urlencode($device->api_key);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Mirror WebUI</flux:heading>
|
||||||
|
<flux:subheading>Mirror this device onto older devices with a web browser — Safari is supported back to iOS 9.</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:input
|
||||||
|
label="Mirror URL"
|
||||||
|
value="{{$mirrorUrl}}"
|
||||||
|
readonly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
@if(!$device->mirror_device_id)
|
@if(!$device->mirror_device_id)
|
||||||
@if($current_image_path)
|
@if($current_image_path)
|
||||||
<flux:separator class="mt-6 mb-6" text="Screen"/>
|
<flux:separator class="mt-6 mb-6" text="Screen"/>
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,16 @@ new class extends Component {
|
||||||
{{--@dump($devices)--}}
|
{{--@dump($devices)--}}
|
||||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Devices</h2>
|
<div class="flex items-center space-x-2">
|
||||||
|
<h2 class="text-2xl font-semibold dark:text-gray-100">Devices</h2>
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button icon="chevron-down" variant="ghost"></flux:button>
|
||||||
|
<flux:menu>
|
||||||
|
<flux:menu.item href="{{ route('device-models.index') }}">Device Models</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="create-device">
|
<flux:modal.trigger name="create-device">
|
||||||
<flux:button icon="plus" variant="primary">Add Device</flux:button>
|
<flux:button icon="plus" variant="primary">Add Device</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,7 @@ new class extends Component {
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|
||||||
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
|
@if($devices->isEmpty() || $devices->every(fn($device) => $device->playlists->isEmpty()))
|
||||||
<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">
|
<div class="px-10 py-8">
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">No playlists found</h1>
|
<h1 class="text-xl font-medium dark:text-zinc-200">No playlists found</h1>
|
||||||
<p class="text-sm dark:text-zinc-400 mt-2">Add playlists to your devices to see them here.</p>
|
<p class="text-sm dark:text-zinc-400 mt-2">Add playlists to your devices to see them here.</p>
|
||||||
|
|
|
||||||
516
resources/views/livewire/plugins/config-modal.blade.php
Normal file
|
|
@ -0,0 +1,516 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This component contains the configuation modal
|
||||||
|
*/
|
||||||
|
new class extends Component {
|
||||||
|
public Plugin $plugin;
|
||||||
|
public array $configuration_template = [];
|
||||||
|
public array $configuration = []; // holds config data
|
||||||
|
|
||||||
|
public array $multiValues = []; // UI boxes for multi_string
|
||||||
|
public array $xhrSelectOptions = [];
|
||||||
|
public array $searchQueries = [];
|
||||||
|
|
||||||
|
// ------------------------------------This section contains one-off functions for the form------------------------------------------------
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this -> loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadData(): void
|
||||||
|
{
|
||||||
|
$this->resetErrorBag();
|
||||||
|
// Reload data
|
||||||
|
$this->plugin = $this->plugin->fresh();
|
||||||
|
|
||||||
|
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
||||||
|
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
|
||||||
|
|
||||||
|
// Initialize multiValues by exploding the CSV strings from the DB
|
||||||
|
foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
|
||||||
|
if (($field['field_type'] ?? null) === 'multi_string') {
|
||||||
|
$fieldKey = $field['keyname'];
|
||||||
|
$rawValue = $this->configuration[$fieldKey] ?? ($field['default'] ?? '');
|
||||||
|
|
||||||
|
$currentValue = is_array($rawValue) ? '' : (string)$rawValue;
|
||||||
|
|
||||||
|
$this->multiValues[$fieldKey] = $currentValue !== ''
|
||||||
|
? array_values(array_filter(explode(',', $currentValue)))
|
||||||
|
: [''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggered by @close on the modal to discard any typed but unsaved changes
|
||||||
|
*/
|
||||||
|
public int $resetIndex = 0; // Add this property
|
||||||
|
public function resetForm(): void
|
||||||
|
{
|
||||||
|
$this->loadData();
|
||||||
|
$this->resetIndex++; // Increment to force DOM refresh
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveConfiguration()
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
// final validation layer
|
||||||
|
$this->validate([
|
||||||
|
'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
|
||||||
|
], [
|
||||||
|
'multiValues.*.*.regex' => 'Items cannot contain commas.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Prepare config copy to send to db
|
||||||
|
$finalValues = $this->configuration;
|
||||||
|
foreach ($this->configuration_template['custom_fields'] ?? [] as $field) {
|
||||||
|
$fieldKey = $field['keyname'];
|
||||||
|
|
||||||
|
// Handle multi_string: Join array back to CSV string
|
||||||
|
if ($field['field_type'] === 'multi_string' && isset($this->multiValues[$fieldKey])) {
|
||||||
|
$finalValues[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle code fields: Try to JSON decode if necessary (standard TRMNL behavior)
|
||||||
|
if ($field['field_type'] === 'code' && isset($finalValues[$fieldKey]) && is_string($finalValues[$fieldKey])) {
|
||||||
|
$decoded = json_decode($finalValues[$fieldKey], true);
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE && (is_array($decoded) || is_object($decoded))) {
|
||||||
|
$finalValues[$fieldKey] = $decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send to db
|
||||||
|
$this->plugin->update(['configuration' => $finalValues]);
|
||||||
|
$this->configuration = $finalValues; // update local state
|
||||||
|
$this->dispatch('config-updated'); // notifies listeners
|
||||||
|
Flux::modal('configuration-modal')->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------This section contains helper functions for interacting with the form------------------------------------------------
|
||||||
|
public function addMultiItem(string $fieldKey): void
|
||||||
|
{
|
||||||
|
$this->multiValues[$fieldKey][] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeMultiItem(string $fieldKey, int $index): void
|
||||||
|
{
|
||||||
|
unset($this->multiValues[$fieldKey][$index]);
|
||||||
|
|
||||||
|
$this->multiValues[$fieldKey] = array_values($this->multiValues[$fieldKey]);
|
||||||
|
|
||||||
|
if (empty($this->multiValues[$fieldKey])) {
|
||||||
|
$this->multiValues[$fieldKey][] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Livewire magic method to validate MultiValue input boxes
|
||||||
|
// Runs on every debounce
|
||||||
|
public function updatedMultiValues($value, $key)
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'multiValues.*.*' => ['nullable', 'string', 'regex:/^[^,]*$/'],
|
||||||
|
], [
|
||||||
|
'multiValues.*.*.regex' => 'Items cannot contain commas.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$requestData = [];
|
||||||
|
if ($query !== null) {
|
||||||
|
$requestData = [
|
||||||
|
'function' => $fieldKey,
|
||||||
|
'query' => $query
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $query !== null
|
||||||
|
? Http::post($endpoint, $requestData)
|
||||||
|
: Http::post($endpoint);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$this->xhrSelectOptions[$fieldKey] = $response->json();
|
||||||
|
} else {
|
||||||
|
$this->xhrSelectOptions[$fieldKey] = [];
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->xhrSelectOptions[$fieldKey] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchXhrSelect(string $fieldKey, string $endpoint): void
|
||||||
|
{
|
||||||
|
$query = $this->searchQueries[$fieldKey] ?? '';
|
||||||
|
if (!empty($query)) {
|
||||||
|
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};?>
|
||||||
|
|
||||||
|
<flux:modal name="configuration-modal" @close="resetForm" class="md:w-96">
|
||||||
|
<div wire:key="config-form-{{ $resetIndex }}" class="space-y-6">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Configuration</flux:heading>
|
||||||
|
<flux:subheading>Configure your plugin settings</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveConfiguration">
|
||||||
|
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
|
||||||
|
@foreach($configuration_template['custom_fields'] as $field)
|
||||||
|
@php
|
||||||
|
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
||||||
|
$rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? '');
|
||||||
|
|
||||||
|
# These are sanitized at Model/Plugin level, safe to render HTML
|
||||||
|
$safeDescription = $field['description'] ?? '';
|
||||||
|
$safeHelp = $field['help_text'] ?? '';
|
||||||
|
|
||||||
|
// For code fields, if the value is an array, JSON encode it
|
||||||
|
if ($field['field_type'] === 'code' && is_array($rawValue)) {
|
||||||
|
$currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
$currentValue = is_array($rawValue) ? '' : (string) $rawValue;
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
<div class="mb-4">
|
||||||
|
@if($field['field_type'] === 'author_bio')
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($field['field_type'] === 'copyable_webhook_url')
|
||||||
|
@continue
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'text')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:textarea
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'code')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:textarea
|
||||||
|
rows="{{ $field['rows'] ?? 3 }}"
|
||||||
|
placeholder="{{ $field['placeholder'] ?? null }}"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
class="font-mono"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'password')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
type="password"
|
||||||
|
wire:model="local_configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
viewable
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'copyable')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
value="{{ $field['value'] }}"
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'time_zone')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:select
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $field['value'] }}"
|
||||||
|
>
|
||||||
|
<option value="">Select timezone...</option>
|
||||||
|
@foreach(timezone_identifiers_list() as $timezone)
|
||||||
|
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'number')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
type="number"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'boolean')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:checkbox
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
:checked="$currentValue"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'date')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
type="date"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'time')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input
|
||||||
|
type="time"
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
value="{{ $currentValue }}"
|
||||||
|
/>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'select')
|
||||||
|
@if(isset($field['multiple']) && $field['multiple'] === true)
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:checkbox.group wire:model="configuration.{{ $fieldKey }}">
|
||||||
|
@if(isset($field['options']) && is_array($field['options']))
|
||||||
|
@foreach($field['options'] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$key = mb_strtolower(str_replace(' ', '_', $option));
|
||||||
|
@endphp
|
||||||
|
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</flux:checkbox.group>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
@else
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:select wire:model="configuration.{{ $fieldKey }}">
|
||||||
|
<option value="">Select {{ $field['name'] }}...</option>
|
||||||
|
@if(isset($field['options']) && is_array($field['options']))
|
||||||
|
@foreach($field['options'] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
@php
|
||||||
|
$key = mb_strtolower(str_replace(' ', '_', $option));
|
||||||
|
@endphp
|
||||||
|
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</flux:select>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'xhrSelect')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:select
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
|
||||||
|
>
|
||||||
|
<option value="">Select {{ $field['name'] }}...</option>
|
||||||
|
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
|
||||||
|
@foreach($xhrSelectOptions[$fieldKey] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@if(isset($option['id']) && isset($option['name']))
|
||||||
|
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
|
||||||
|
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
|
||||||
|
@else
|
||||||
|
{{-- xhrSelect format: { 'Braves' => 123 } --}}
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
</flux:select>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@elseif($field['field_type'] === 'xhrSelectSearch')
|
||||||
|
<div class="space-y-2">
|
||||||
|
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
<flux:input.group>
|
||||||
|
<flux:input
|
||||||
|
wire:model="searchQueries.{{ $fieldKey }}"
|
||||||
|
placeholder="Enter search query..."
|
||||||
|
/>
|
||||||
|
<flux:button
|
||||||
|
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
|
||||||
|
icon="magnifying-glass"/>
|
||||||
|
</flux:input.group>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
|
||||||
|
<flux:select
|
||||||
|
wire:model="configuration.{{ $fieldKey }}"
|
||||||
|
>
|
||||||
|
<option value="">Select {{ $field['name'] }}...</option>
|
||||||
|
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
|
||||||
|
@foreach($xhrSelectOptions[$fieldKey] as $option)
|
||||||
|
@if(is_array($option))
|
||||||
|
@if(isset($option['id']) && isset($option['name']))
|
||||||
|
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
|
||||||
|
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
|
||||||
|
@else
|
||||||
|
{{-- xhrSelect format: { 'Braves' => 123 } --}}
|
||||||
|
@foreach($option as $label => $value)
|
||||||
|
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
|
||||||
|
@endif
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
|
||||||
|
{{-- Show current value even if no options are loaded --}}
|
||||||
|
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
|
||||||
|
@endif
|
||||||
|
</flux:select>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif($field['field_type'] === 'multi_string')
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>{{ $field['name'] }}</flux:label>
|
||||||
|
<flux:description>{!! $safeDescription !!}</flux:description>
|
||||||
|
|
||||||
|
<div class="space-y-2 mt-2">
|
||||||
|
@foreach($multiValues[$fieldKey] as $index => $item)
|
||||||
|
<div class="flex gap-2 items-center"
|
||||||
|
wire:key="multi-{{ $fieldKey }}-{{ $index }}">
|
||||||
|
|
||||||
|
<flux:input
|
||||||
|
wire:model.live.debounce="multiValues.{{ $fieldKey }}.{{ $index }}"
|
||||||
|
:placeholder="$field['placeholder'] ?? 'Value...'"
|
||||||
|
:invalid="$errors->has('multiValues.'.$fieldKey.'.'.$index)"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if(count($multiValues[$fieldKey]) > 1)
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
icon="trash"
|
||||||
|
size="sm"
|
||||||
|
wire:click="removeMultiItem('{{ $fieldKey }}', {{ $index }})"
|
||||||
|
/>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@error("multiValues.{$fieldKey}.{$index}")
|
||||||
|
<div class="flex items-center gap-2 mt-1 text-amber-600">
|
||||||
|
<flux:icon name="exclamation-triangle" variant="micro" />
|
||||||
|
{{-- $message comes from thrown error --}}
|
||||||
|
<span class="text-xs font-medium">{{ $message }}</span>
|
||||||
|
</div>
|
||||||
|
@enderror
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<flux:button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon="plus"
|
||||||
|
wire:click="addMultiItem('{{ $fieldKey }}')"
|
||||||
|
>
|
||||||
|
Add Item
|
||||||
|
</flux:button>
|
||||||
|
</div>
|
||||||
|
<flux:description>{!! $safeHelp !!}</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
@else
|
||||||
|
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="flex-col space-y-2 items-end w-full">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
:disabled="$errors->any()"
|
||||||
|
class="disabled:opacity-50 disabled:cursor-not-allowed disabled:grayscale"
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</flux:button>
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="flex items-center gap-2 text-amber-600">
|
||||||
|
<flux:icon name="exclamation-circle" variant="micro" />
|
||||||
|
<span class="text-sm font-medium">
|
||||||
|
Fix errors before saving.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public Plugin $plugin;
|
||||||
|
public string $name;
|
||||||
|
public array $checked_devices = [];
|
||||||
|
public array $device_playlists = [];
|
||||||
|
public array $device_playlist_names = [];
|
||||||
|
public array $device_weekdays = [];
|
||||||
|
public array $device_active_from = [];
|
||||||
|
public array $device_active_until = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
abort_unless($this->plugin->plugin_type === 'image_webhook', 404);
|
||||||
|
|
||||||
|
$this->name = $this->plugin->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected array $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'checked_devices' => 'array',
|
||||||
|
'device_playlist_names' => 'array',
|
||||||
|
'device_playlists' => 'array',
|
||||||
|
'device_weekdays' => 'array',
|
||||||
|
'device_active_from' => 'array',
|
||||||
|
'device_active_until' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function updateName(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
$this->validate(['name' => 'required|string|max:255']);
|
||||||
|
$this->plugin->update(['name' => $this->name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function addToPlaylist()
|
||||||
|
{
|
||||||
|
$this->validate([
|
||||||
|
'checked_devices' => 'required|array|min:1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($this->checked_devices as $deviceId) {
|
||||||
|
if (!isset($this->device_playlists[$deviceId]) || empty($this->device_playlists[$deviceId])) {
|
||||||
|
$this->addError('device_playlists.' . $deviceId, 'Please select a playlist for each device.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->device_playlists[$deviceId] === 'new') {
|
||||||
|
if (!isset($this->device_playlist_names[$deviceId]) || empty($this->device_playlist_names[$deviceId])) {
|
||||||
|
$this->addError('device_playlist_names.' . $deviceId, 'Playlist name is required when creating a new playlist.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->checked_devices as $deviceId) {
|
||||||
|
$playlist = null;
|
||||||
|
|
||||||
|
if ($this->device_playlists[$deviceId] === 'new') {
|
||||||
|
$playlist = \App\Models\Playlist::create([
|
||||||
|
'device_id' => $deviceId,
|
||||||
|
'name' => $this->device_playlist_names[$deviceId],
|
||||||
|
'weekdays' => !empty($this->device_weekdays[$deviceId] ?? null) ? $this->device_weekdays[$deviceId] : null,
|
||||||
|
'active_from' => $this->device_active_from[$deviceId] ?? null,
|
||||||
|
'active_until' => $this->device_active_until[$deviceId] ?? null,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$playlist = \App\Models\Playlist::findOrFail($this->device_playlists[$deviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxOrder = $playlist->items()->max('order') ?? 0;
|
||||||
|
|
||||||
|
// Image webhook plugins only support full layout
|
||||||
|
$playlist->items()->create([
|
||||||
|
'plugin_id' => $this->plugin->id,
|
||||||
|
'order' => $maxOrder + 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reset([
|
||||||
|
'checked_devices',
|
||||||
|
'device_playlists',
|
||||||
|
'device_playlist_names',
|
||||||
|
'device_weekdays',
|
||||||
|
'device_active_from',
|
||||||
|
'device_active_until',
|
||||||
|
]);
|
||||||
|
Flux::modal('add-to-playlist')->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDevicePlaylists($deviceId)
|
||||||
|
{
|
||||||
|
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasAnyPlaylistSelected(): bool
|
||||||
|
{
|
||||||
|
foreach ($this->checked_devices as $deviceId) {
|
||||||
|
if (isset($this->device_playlists[$deviceId]) && !empty($this->device_playlists[$deviceId])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePlugin(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
$this->plugin->delete();
|
||||||
|
$this->redirect(route('plugins.image-webhook'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getImagePath(): ?string
|
||||||
|
{
|
||||||
|
if (!$this->plugin->current_image) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extensions = ['png', 'bmp'];
|
||||||
|
foreach ($extensions as $ext) {
|
||||||
|
$path = 'images/generated/'.$this->plugin->current_image.'.'.$ext;
|
||||||
|
if (\Illuminate\Support\Facades\Storage::disk('public')->exists($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<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">Image Webhook – {{$plugin->name}}</h2>
|
||||||
|
|
||||||
|
<flux:button.group>
|
||||||
|
<flux:modal.trigger name="add-to-playlist">
|
||||||
|
<flux:button icon="play" variant="primary">Add to Playlist</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
|
||||||
|
<flux:dropdown>
|
||||||
|
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||||
|
<flux:menu>
|
||||||
|
<flux:modal.trigger name="delete-plugin">
|
||||||
|
<flux:menu.item icon="trash" variant="danger">Delete Instance</flux:menu.item>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</flux:menu>
|
||||||
|
</flux:dropdown>
|
||||||
|
</flux:button.group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:modal name="add-to-playlist" class="min-w-2xl">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Add to Playlist</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="addToPlaylist">
|
||||||
|
<flux:separator text="Device(s)" />
|
||||||
|
<div class="mt-4 mb-4">
|
||||||
|
<flux:checkbox.group wire:model.live="checked_devices">
|
||||||
|
@foreach(auth()->user()->devices as $device)
|
||||||
|
<flux:checkbox label="{{ $device->name }}" value="{{ $device->id }}"/>
|
||||||
|
@endforeach
|
||||||
|
</flux:checkbox.group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(count($checked_devices) > 0)
|
||||||
|
<flux:separator text="Playlist Selection" />
|
||||||
|
<div class="mt-4 mb-4 space-y-6">
|
||||||
|
@foreach($checked_devices as $deviceId)
|
||||||
|
@php
|
||||||
|
$device = auth()->user()->devices->find($deviceId);
|
||||||
|
@endphp
|
||||||
|
<div class="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4">
|
||||||
|
<div class="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||||
|
{{ $device->name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:select wire:model.live.debounce="device_playlists.{{ $deviceId }}">
|
||||||
|
<option value="">Select Playlist or Create New</option>
|
||||||
|
@foreach($this->getDevicePlaylists($deviceId) as $playlist)
|
||||||
|
<option value="{{ $playlist->id }}">{{ $playlist->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
<option value="new">Create New Playlist</option>
|
||||||
|
</flux:select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(isset($device_playlists[$deviceId]) && $device_playlists[$deviceId] === 'new')
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<flux:input label="Playlist Name" wire:model="device_playlist_names.{{ $deviceId }}"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:checkbox.group wire:model="device_weekdays.{{ $deviceId }}" label="Active Days (optional)">
|
||||||
|
<flux:checkbox label="Monday" value="1"/>
|
||||||
|
<flux:checkbox label="Tuesday" value="2"/>
|
||||||
|
<flux:checkbox label="Wednesday" value="3"/>
|
||||||
|
<flux:checkbox label="Thursday" value="4"/>
|
||||||
|
<flux:checkbox label="Friday" value="5"/>
|
||||||
|
<flux:checkbox label="Saturday" value="6"/>
|
||||||
|
<flux:checkbox label="Sunday" value="0"/>
|
||||||
|
</flux:checkbox.group>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<flux:input type="time" label="Active From (optional)" wire:model="device_active_from.{{ $deviceId }}"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<flux:input type="time" label="Active Until (optional)" wire:model="device_active_until.{{ $deviceId }}"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary">Add to Playlist</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
<flux:modal name="delete-plugin" class="min-w-[22rem] space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Delete {{ $plugin->name }}?</flux:heading>
|
||||||
|
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button variant="ghost">Cancel</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
<flux:button wire:click="deletePlugin" variant="danger">Delete instance</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
<div class="grid lg:grid-cols-2 lg:gap-8">
|
||||||
|
<div>
|
||||||
|
<form wire:submit="updateName" class="mb-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||||
|
name="name" autofocus/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary" class="w-full">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<flux:label>Webhook URL</flux:label>
|
||||||
|
<flux:input
|
||||||
|
:value="route('api.plugin_settings.image', ['uuid' => $plugin->uuid])"
|
||||||
|
class="font-mono text-sm"
|
||||||
|
readonly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<flux:description class="mt-2">POST an image (PNG or BMP) to this URL to update the displayed image.</flux:description>
|
||||||
|
|
||||||
|
<flux:callout variant="warning" icon="exclamation-circle" class="mt-4">
|
||||||
|
<flux:callout.text>Images must be posted in a format that can directly be read by the device. You need to take care of image format, dithering, and bit-depth. Check device logs if the image is not shown.</flux:callout.text>
|
||||||
|
</flux:callout>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:label>Current Image</flux:label>
|
||||||
|
@if($this->getImagePath())
|
||||||
|
<img src="{{ url('storage/'.$this->getImagePath()) }}" alt="{{ $plugin->name }}" class="w-full h-auto rounded-lg border border-zinc-200 dark:border-zinc-700 mt-2" />
|
||||||
|
@else
|
||||||
|
<flux:callout variant="warning" class="mt-2">
|
||||||
|
<flux:text>No image uploaded yet. POST an image to the webhook URL to get started.</flux:text>
|
||||||
|
</flux:callout>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
163
resources/views/livewire/plugins/image-webhook.blade.php
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
new class extends Component {
|
||||||
|
public string $name = '';
|
||||||
|
public array $instances = [];
|
||||||
|
|
||||||
|
protected $rules = [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->refreshInstances();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshInstances(): void
|
||||||
|
{
|
||||||
|
$this->instances = auth()->user()
|
||||||
|
->plugins()
|
||||||
|
->where('plugin_type', 'image_webhook')
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createInstance(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user() !== null, 403);
|
||||||
|
$this->validate();
|
||||||
|
|
||||||
|
Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'name' => $this->name,
|
||||||
|
'plugin_type' => 'image_webhook',
|
||||||
|
'data_strategy' => 'static', // Not used for image_webhook, but required
|
||||||
|
'data_stale_minutes' => 60, // Not used for image_webhook, but required
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->reset(['name']);
|
||||||
|
$this->refreshInstances();
|
||||||
|
|
||||||
|
Flux::modal('create-instance')->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteInstance(int $pluginId): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user() !== null, 403);
|
||||||
|
|
||||||
|
$plugin = Plugin::where('id', $pluginId)
|
||||||
|
->where('user_id', auth()->id())
|
||||||
|
->where('plugin_type', 'image_webhook')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$plugin->delete();
|
||||||
|
$this->refreshInstances();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<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">Image Webhook
|
||||||
|
<flux:badge size="sm" class="ml-2">Plugin</flux:badge>
|
||||||
|
</h2>
|
||||||
|
<flux:modal.trigger name="create-instance">
|
||||||
|
<flux:button icon="plus" variant="primary">Create Instance</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:modal name="create-instance" class="md:w-96">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Create Image Webhook Instance</flux:heading>
|
||||||
|
<flux:subheading>Create a new instance that accepts images via webhook</flux:subheading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="createInstance">
|
||||||
|
<div class="mb-4">
|
||||||
|
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||||
|
name="name" autofocus/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:button type="submit" variant="primary">Create Instance</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
||||||
|
@if(empty($instances))
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<flux:callout>
|
||||||
|
<flux:heading size="sm">No instances yet</flux:heading>
|
||||||
|
<flux:text>Create your first Image Webhook instance to get started.</flux:text>
|
||||||
|
</flux:callout>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<table
|
||||||
|
class="min-w-full table-auto text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20"
|
||||||
|
data-flux-table="">
|
||||||
|
<thead data-flux-columns="">
|
||||||
|
<tr>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column="">
|
||||||
|
<div class="whitespace-nowrap flex">Name</div>
|
||||||
|
</th>
|
||||||
|
<th class="py-3 px-3 first:pl-0 last:pr-0 text-right text-sm font-medium text-zinc-800 dark:text-white"
|
||||||
|
data-flux-column="">
|
||||||
|
<div class="whitespace-nowrap flex justify-end">Actions</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows="">
|
||||||
|
@foreach($instances as $instance)
|
||||||
|
<tr data-flux-row="">
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300">
|
||||||
|
{{ $instance['name'] }}
|
||||||
|
</td>
|
||||||
|
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white text-right">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<flux:button.group>
|
||||||
|
<flux:button href="{{ route('plugins.image-webhook-instance', ['plugin' => $instance['id']]) }}" wire:navigate icon="pencil" iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
<flux:modal.trigger name="delete-instance-{{ $instance['id'] }}">
|
||||||
|
<flux:button icon="trash" iconVariant="outline">
|
||||||
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
</flux:button.group>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach($instances as $instance)
|
||||||
|
<flux:modal name="delete-instance-{{ $instance['id'] }}" class="min-w-88 space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Delete {{ $instance['name'] }}?</flux:heading>
|
||||||
|
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">This will also remove this instance from your playlists.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button variant="ghost">Cancel</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
<flux:button wire:click="deleteInstance({{ $instance['id'] }})" variant="danger">Delete instance</flux:button>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
@ -26,6 +26,8 @@ new class extends Component {
|
||||||
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
['name' => 'Markup', 'flux_icon_name' => 'code-bracket', 'detail_view_route' => 'plugins.markup'],
|
||||||
'api' =>
|
'api' =>
|
||||||
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
|
['name' => 'API', 'flux_icon_name' => 'braces', 'detail_view_route' => 'plugins.api'],
|
||||||
|
'image-webhook' =>
|
||||||
|
['name' => 'Image Webhook', 'flux_icon_name' => 'photo', 'detail_view_route' => 'plugins.image-webhook'],
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $rules = [
|
protected $rules = [
|
||||||
|
|
@ -40,7 +42,12 @@ new class extends Component {
|
||||||
|
|
||||||
public function refreshPlugins(): void
|
public function refreshPlugins(): void
|
||||||
{
|
{
|
||||||
$userPlugins = auth()->user()?->plugins?->makeHidden(['render_markup', 'data_payload'])->toArray();
|
// Only show recipe plugins in the main list (image_webhook has its own management page)
|
||||||
|
$userPlugins = auth()->user()?->plugins()
|
||||||
|
->where('plugin_type', 'recipe')
|
||||||
|
->get()
|
||||||
|
->makeHidden(['render_markup', 'data_payload'])
|
||||||
|
->toArray();
|
||||||
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
$allPlugins = array_merge($this->native_plugins, $userPlugins ?? []);
|
||||||
$allPlugins = array_values($allPlugins);
|
$allPlugins = array_values($allPlugins);
|
||||||
$allPlugins = $this->sortPlugins($allPlugins);
|
$allPlugins = $this->sortPlugins($allPlugins);
|
||||||
|
|
@ -388,7 +395,7 @@ new class extends Component {
|
||||||
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
|
wire:key="plugin-{{ $plugin['id'] ?? $plugin['name'] ?? $index }}"
|
||||||
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
|
x-data="{ pluginName: {{ json_encode(strtolower($plugin['name'] ?? '')) }} }"
|
||||||
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
|
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
|
||||||
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
class="styled-container">
|
||||||
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
|
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
|
||||||
class="block h-full">
|
class="block h-full">
|
||||||
<div class="flex items-center space-x-4 px-10 py-8 h-full">
|
<div class="flex items-center space-x-4 px-10 py-8 h-full">
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Device;
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
|
use App\Models\DeviceModel;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||||
use Livewire\Volt\Component;
|
use Livewire\Volt\Component;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Attributes\On;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
|
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public Plugin $plugin;
|
public Plugin $plugin;
|
||||||
|
|
@ -34,16 +38,15 @@ new class extends Component {
|
||||||
public string $mashup_layout = 'full';
|
public string $mashup_layout = 'full';
|
||||||
public array $mashup_plugins = [];
|
public array $mashup_plugins = [];
|
||||||
public array $configuration_template = [];
|
public array $configuration_template = [];
|
||||||
public array $configuration = [];
|
public ?int $preview_device_model_id = null;
|
||||||
public array $xhrSelectOptions = [];
|
public string $preview_size = 'full';
|
||||||
public array $searchQueries = [];
|
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
$this->blade_code = $this->plugin->render_markup;
|
$this->blade_code = $this->plugin->render_markup;
|
||||||
|
// required to render some stuff
|
||||||
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
$this->configuration_template = $this->plugin->configuration_template ?? [];
|
||||||
$this->configuration = is_array($this->plugin->configuration) ? $this->plugin->configuration : [];
|
|
||||||
|
|
||||||
if ($this->plugin->render_markup_view) {
|
if ($this->plugin->render_markup_view) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -74,6 +77,12 @@ new class extends Component {
|
||||||
|
|
||||||
$this->fillformFields();
|
$this->fillformFields();
|
||||||
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
|
$this->data_payload_updated_at = $this->plugin->data_payload_updated_at;
|
||||||
|
|
||||||
|
// Set default preview device model
|
||||||
|
if ($this->preview_device_model_id === null) {
|
||||||
|
$defaultModel = DeviceModel::where('name', 'og_plus')->first() ?? DeviceModel::first();
|
||||||
|
$this->preview_device_model_id = $defaultModel?->id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fillFormFields(): void
|
public function fillFormFields(): void
|
||||||
|
|
@ -129,6 +138,19 @@ new class extends Component {
|
||||||
$validated = $this->validate();
|
$validated = $this->validate();
|
||||||
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
$validated['data_payload'] = json_decode(Arr::get($validated,'data_payload'), true);
|
||||||
$this->plugin->update($validated);
|
$this->plugin->update($validated);
|
||||||
|
|
||||||
|
foreach ($this->configuration_template as $fieldKey => $field) {
|
||||||
|
if (($field['field_type'] ?? null) !== 'multi_string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($this->multiValues[$fieldKey])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated[$fieldKey] = implode(',', array_filter(array_map('trim', $this->multiValues[$fieldKey])));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function validatePollingUrl(): void
|
protected function validatePollingUrl(): void
|
||||||
|
|
@ -254,27 +276,6 @@ new class extends Component {
|
||||||
Flux::modal('add-to-playlist')->close();
|
Flux::modal('add-to-playlist')->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveConfiguration()
|
|
||||||
{
|
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
|
||||||
|
|
||||||
$configurationValues = [];
|
|
||||||
if (isset($this->configuration_template['custom_fields'])) {
|
|
||||||
foreach ($this->configuration_template['custom_fields'] as $field) {
|
|
||||||
$fieldKey = $field['keyname'];
|
|
||||||
if (isset($this->configuration[$fieldKey])) {
|
|
||||||
$configurationValues[$fieldKey] = $this->configuration[$fieldKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->plugin->update([
|
|
||||||
'configuration' => $configurationValues
|
|
||||||
]);
|
|
||||||
|
|
||||||
Flux::modal('configuration-modal')->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDevicePlaylists($deviceId)
|
public function getDevicePlaylists($deviceId)
|
||||||
{
|
{
|
||||||
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
return \App\Models\Playlist::where('device_id', $deviceId)->get();
|
||||||
|
|
@ -295,8 +296,6 @@ new class extends Component {
|
||||||
return $this->configuration[$key] ?? $default;
|
return $this->configuration[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function renderExample(string $example)
|
public function renderExample(string $example)
|
||||||
{
|
{
|
||||||
switch ($example) {
|
switch ($example) {
|
||||||
|
|
@ -365,13 +364,17 @@ HTML;
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
$this->preview_size = $size;
|
||||||
|
|
||||||
// If data strategy is polling and data_payload is null, fetch the data first
|
// If data strategy is polling and data_payload is null, fetch the data first
|
||||||
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
|
if ($this->plugin->data_strategy === 'polling' && $this->plugin->data_payload === null) {
|
||||||
$this->updateData();
|
$this->updateData();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$previewMarkup = $this->plugin->render($size);
|
// Create a device object with og_plus model and the selected bitdepth
|
||||||
|
$device = $this->createPreviewDevice();
|
||||||
|
$previewMarkup = $this->plugin->render($size, true, $device);
|
||||||
$this->dispatch('preview-updated', preview: $previewMarkup);
|
$this->dispatch('preview-updated', preview: $previewMarkup);
|
||||||
} catch (LiquidException $e) {
|
} catch (LiquidException $e) {
|
||||||
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
|
$this->dispatch('preview-error', message: $e->toLiquidErrorMessage());
|
||||||
|
|
@ -380,6 +383,38 @@ HTML;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function createPreviewDevice(): \App\Models\Device
|
||||||
|
{
|
||||||
|
$deviceModel = DeviceModel::with(['palette'])->find($this->preview_device_model_id)
|
||||||
|
?? DeviceModel::with(['palette'])->first();
|
||||||
|
|
||||||
|
$device = new Device();
|
||||||
|
$device->setRelation('deviceModel', $deviceModel);
|
||||||
|
|
||||||
|
return $device;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDeviceModels()
|
||||||
|
{
|
||||||
|
return DeviceModel::whereKind('trmnl')->orderBy('label')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedPreviewDeviceModelId(): void
|
||||||
|
{
|
||||||
|
$this->renderPreview($this->preview_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function duplicatePlugin(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
// Use the model's duplicate method
|
||||||
|
$newPlugin = $this->plugin->duplicate(auth()->id());
|
||||||
|
|
||||||
|
// Redirect to the new plugin's detail page
|
||||||
|
$this->redirect(route('plugins.recipe', ['plugin' => $newPlugin]));
|
||||||
|
}
|
||||||
|
|
||||||
public function deletePlugin(): void
|
public function deletePlugin(): void
|
||||||
{
|
{
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
@ -387,42 +422,31 @@ HTML;
|
||||||
$this->redirect(route('plugins.index'));
|
$this->redirect(route('plugins.index'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadXhrSelectOptions(string $fieldKey, string $endpoint, ?string $query = null): void
|
#[On('config-updated')]
|
||||||
{
|
public function refreshPlugin()
|
||||||
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
{
|
||||||
|
// This pulls the fresh 'configuration' from the DB
|
||||||
|
// and re-triggers the @if check in the Blade template
|
||||||
|
$this->plugin = $this->plugin->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
// Laravel Livewire computed property: access with $this->parsed_urls
|
||||||
$requestData = [];
|
#[Computed]
|
||||||
if ($query !== null) {
|
private function parsedUrls()
|
||||||
$requestData = [
|
{
|
||||||
'function' => $fieldKey,
|
if (!isset($this->polling_url)) {
|
||||||
'query' => $query
|
return null;
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $query !== null
|
|
||||||
? Http::post($endpoint, $requestData)
|
|
||||||
: Http::post($endpoint);
|
|
||||||
|
|
||||||
if ($response->successful()) {
|
|
||||||
$this->xhrSelectOptions[$fieldKey] = $response->json();
|
|
||||||
} else {
|
|
||||||
$this->xhrSelectOptions[$fieldKey] = [];
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$this->xhrSelectOptions[$fieldKey] = [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function searchXhrSelect(string $fieldKey, string $endpoint): void
|
try {
|
||||||
{
|
return $this->plugin->resolveLiquidVariables($this->polling_url);
|
||||||
$query = $this->searchQueries[$fieldKey] ?? '';
|
|
||||||
if (!empty($query)) {
|
} catch (\Exception $e) {
|
||||||
$this->loadXhrSelectOptions($fieldKey, $endpoint, $query);
|
return 'PARSE_ERROR: ' . $e->getMessage();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="py-12">
|
<div class="py-12">
|
||||||
|
|
@ -454,7 +478,6 @@ HTML;
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</flux:menu>
|
</flux:menu>
|
||||||
</flux:dropdown>
|
</flux:dropdown>
|
||||||
|
|
||||||
</flux:button.group>
|
</flux:button.group>
|
||||||
<flux:button.group>
|
<flux:button.group>
|
||||||
<flux:modal.trigger name="add-to-playlist">
|
<flux:modal.trigger name="add-to-playlist">
|
||||||
|
|
@ -464,6 +487,11 @@ HTML;
|
||||||
<flux:dropdown>
|
<flux:dropdown>
|
||||||
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
<flux:button icon="chevron-down" variant="primary"></flux:button>
|
||||||
<flux:menu>
|
<flux:menu>
|
||||||
|
<flux:modal.trigger name="trmnlp-settings">
|
||||||
|
<flux:menu.item icon="cog">Recipe Settings</flux:menu.item>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
<flux:menu.separator />
|
||||||
|
<flux:menu.item icon="document-duplicate" wire:click="duplicatePlugin">Duplicate Plugin</flux:menu.item>
|
||||||
<flux:modal.trigger name="delete-plugin">
|
<flux:modal.trigger name="delete-plugin">
|
||||||
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
|
<flux:menu.item icon="trash" variant="danger">Delete Plugin</flux:menu.item>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
|
|
@ -605,8 +633,15 @@ HTML;
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
|
<flux:modal name="preview-plugin" class="min-w-[850px] min-h-[480px] space-y-6">
|
||||||
<div>
|
<div class="flex items-center gap-4">
|
||||||
<flux:heading size="lg">Preview {{ $plugin->name }}</flux:heading>
|
<flux:heading size="lg">Preview {{ $plugin->name }}</flux:heading>
|
||||||
|
<flux:field class="w-48">
|
||||||
|
<flux:select wire:model.live="preview_device_model_id">
|
||||||
|
@foreach($this->getDeviceModels() as $model)
|
||||||
|
<option value="{{ $model->id }}">{{ $model->label ?? $model->name }}</option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
</flux:field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
|
|
@ -614,262 +649,9 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
|
|
||||||
<flux:modal name="configuration-modal" class="md:w-96">
|
<livewire:plugins.recipes.settings :plugin="$plugin" />
|
||||||
<div class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<flux:heading size="lg">Configuration</flux:heading>
|
|
||||||
<flux:subheading>Configure your plugin settings</flux:subheading>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form wire:submit="saveConfiguration">
|
<livewire:plugins.config-modal :plugin="$plugin" />
|
||||||
@if(isset($configuration_template['custom_fields']) && is_array($configuration_template['custom_fields']))
|
|
||||||
@foreach($configuration_template['custom_fields'] as $field)
|
|
||||||
@php
|
|
||||||
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
|
||||||
$currentValue = $configuration[$fieldKey] ?? '';
|
|
||||||
@endphp
|
|
||||||
<div class="mb-4">
|
|
||||||
@if($field['field_type'] === 'author_bio')
|
|
||||||
@continue
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($field['field_type'] === 'copyable_webhook_url')
|
|
||||||
@continue
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($field['field_type'] === 'string' || $field['field_type'] === 'url')
|
|
||||||
<flux:input
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'text')
|
|
||||||
<flux:textarea
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'code')
|
|
||||||
<flux:textarea
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
rows="{{ $field['rows'] ?? 3 }}"
|
|
||||||
placeholder="{{ $field['placeholder'] ?? null }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
class="font-mono"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'password')
|
|
||||||
<flux:input
|
|
||||||
type="password"
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
viewable
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'copyable')
|
|
||||||
<flux:input
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
value="{{ $field['value'] }}"
|
|
||||||
copyable
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'time_zone')
|
|
||||||
<flux:select
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
>
|
|
||||||
<option value="">Select timezone...</option>
|
|
||||||
@foreach(timezone_identifiers_list() as $timezone)
|
|
||||||
<option value="{{ $timezone }}" {{ $currentValue === $timezone ? 'selected' : '' }}>{{ $timezone }}</option>
|
|
||||||
@endforeach
|
|
||||||
</flux:select>
|
|
||||||
@elseif($field['field_type'] === 'number')
|
|
||||||
<flux:input
|
|
||||||
type="number"
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? $field['name'] }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'boolean')
|
|
||||||
<flux:checkbox
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? $field['name'] }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
:checked="$currentValue"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'date')
|
|
||||||
<flux:input
|
|
||||||
type="date"
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? $field['name'] }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'time')
|
|
||||||
<flux:input
|
|
||||||
type="time"
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? $field['name'] }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
/>
|
|
||||||
@elseif($field['field_type'] === 'select')
|
|
||||||
@if(isset($field['multiple']) && $field['multiple'] === true)
|
|
||||||
<flux:checkbox.group
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
>
|
|
||||||
@if(isset($field['options']) && is_array($field['options']))
|
|
||||||
@foreach($field['options'] as $option)
|
|
||||||
@if(is_array($option))
|
|
||||||
@foreach($option as $label => $value)
|
|
||||||
<flux:checkbox label="{{ $label }}" value="{{ $value }}"/>
|
|
||||||
@endforeach
|
|
||||||
@else
|
|
||||||
@php
|
|
||||||
$key = mb_strtolower(str_replace(' ', '_', $option));
|
|
||||||
@endphp
|
|
||||||
<flux:checkbox label="{{ $option }}" value="{{ $key }}"/>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
</flux:checkbox.group>
|
|
||||||
@else
|
|
||||||
<flux:select
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
>
|
|
||||||
<option value="">Select {{ $field['name'] }}...</option>
|
|
||||||
@if(isset($field['options']) && is_array($field['options']))
|
|
||||||
@foreach($field['options'] as $option)
|
|
||||||
@if(is_array($option))
|
|
||||||
@foreach($option as $label => $value)
|
|
||||||
<option value="{{ $value }}" {{ $currentValue === $value ? 'selected' : '' }}>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
@else
|
|
||||||
@php
|
|
||||||
$key = mb_strtolower(str_replace(' ', '_', $option));
|
|
||||||
@endphp
|
|
||||||
<option value="{{ $key }}" {{ $currentValue === $key ? 'selected' : '' }}>{{ $option }}</option>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
</flux:select>
|
|
||||||
@endif
|
|
||||||
@elseif($field['field_type'] === 'xhrSelect')
|
|
||||||
<flux:select
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? '' }}"
|
|
||||||
wire:init="loadXhrSelectOptions('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
|
|
||||||
>
|
|
||||||
<option value="">Select {{ $field['name'] }}...</option>
|
|
||||||
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
|
|
||||||
@foreach($xhrSelectOptions[$fieldKey] as $option)
|
|
||||||
@if(is_array($option))
|
|
||||||
@if(isset($option['id']) && isset($option['name']))
|
|
||||||
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
|
|
||||||
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
|
|
||||||
@else
|
|
||||||
{{-- xhrSelect format: { 'Braves' => 123 } --}}
|
|
||||||
@foreach($option as $label => $value)
|
|
||||||
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
</flux:select>
|
|
||||||
@elseif($field['field_type'] === 'xhrSelectSearch')
|
|
||||||
<div class="space-y-2">
|
|
||||||
|
|
||||||
<flux:label>{{ $field['name'] }}</flux:label>
|
|
||||||
<flux:description>{{ $field['description'] ?? '' }}</flux:description>
|
|
||||||
<flux:input.group>
|
|
||||||
<flux:input
|
|
||||||
wire:model="searchQueries.{{ $fieldKey }}"
|
|
||||||
placeholder="Enter search query..."
|
|
||||||
/>
|
|
||||||
<flux:button
|
|
||||||
wire:click="searchXhrSelect('{{ $fieldKey }}', '{{ $field['endpoint'] }}')"
|
|
||||||
icon="magnifying-glass"/>
|
|
||||||
</flux:input.group>
|
|
||||||
<flux:description>{{ $field['help_text'] ?? '' }}</flux:description>
|
|
||||||
@if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue))
|
|
||||||
<flux:select
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
>
|
|
||||||
<option value="">Select {{ $field['name'] }}...</option>
|
|
||||||
@if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]))
|
|
||||||
@foreach($xhrSelectOptions[$fieldKey] as $option)
|
|
||||||
@if(is_array($option))
|
|
||||||
@if(isset($option['id']) && isset($option['name']))
|
|
||||||
{{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}}
|
|
||||||
<option value="{{ $option['id'] }}" {{ $currentValue === (string)$option['id'] ? 'selected' : '' }}>{{ $option['name'] }}</option>
|
|
||||||
@else
|
|
||||||
{{-- xhrSelect format: { 'Braves' => 123 } --}}
|
|
||||||
@foreach($option as $label => $value)
|
|
||||||
<option value="{{ $value }}" {{ $currentValue === (string)$value ? 'selected' : '' }}>{{ $label }}</option>
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
@else
|
|
||||||
<option value="{{ $option }}" {{ $currentValue === (string)$option ? 'selected' : '' }}>{{ $option }}</option>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
@if(!empty($currentValue) && (!isset($xhrSelectOptions[$fieldKey]) || empty($xhrSelectOptions[$fieldKey])))
|
|
||||||
{{-- Show current value even if no options are loaded --}}
|
|
||||||
<option value="{{ $currentValue }}" selected>{{ $currentValue }}</option>
|
|
||||||
@endif
|
|
||||||
</flux:select>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@elseif($field['field_type'] === 'multi_string')
|
|
||||||
<flux:input
|
|
||||||
label="{{ $field['name'] }}"
|
|
||||||
description="{{ $field['description'] ?? '' }}"
|
|
||||||
descriptionTrailing="{{ $field['help_text'] ?? 'Enter multiple values separated by commas' }}"
|
|
||||||
wire:model="configuration.{{ $fieldKey }}"
|
|
||||||
value="{{ $currentValue }}"
|
|
||||||
placeholder="{{ $field['placeholder'] ?? 'value1,value2' }}"
|
|
||||||
/>
|
|
||||||
@else
|
|
||||||
<flux:callout variant="warning">Field type "{{ $field['field_type'] }}" not yet supported</flux:callout>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex">
|
|
||||||
<flux:spacer/>
|
|
||||||
<flux:button type="submit" variant="primary">Save Configuration</flux:button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</flux:modal>
|
|
||||||
|
|
||||||
<div class="mt-5 mb-5">
|
<div class="mt-5 mb-5">
|
||||||
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
<h3 class="text-xl font-semibold dark:text-gray-100">Settings</h3>
|
||||||
|
|
@ -957,7 +739,7 @@ HTML;
|
||||||
@endif
|
@endif
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:modal.trigger name="configuration-modal">
|
<flux:modal.trigger name="configuration-modal">
|
||||||
<flux:button icon="cog" class="block mt-1 w-full">Configuration</flux:button>
|
<flux:button icon="variable" class="block mt-1 w-full">Configuration Fields</flux:button>
|
||||||
</flux:modal.trigger>
|
</flux:modal.trigger>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -970,15 +752,62 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($data_strategy === 'polling')
|
@if($data_strategy === 'polling')
|
||||||
<div class="mb-4">
|
<flux:label>Polling URL</flux:label>
|
||||||
<flux:textarea label="Polling URL" description="You can use configuration variables with Liquid syntax. Supports multiple requests via line break separation" wire:model="polling_url" id="polling_url"
|
|
||||||
|
<div x-data="{ subTab: 'settings' }" class="mt-2 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<button
|
||||||
|
@click="subTab = 'settings'"
|
||||||
|
class="tab-button"
|
||||||
|
:class="subTab === 'settings' ? 'is-active' : ''"
|
||||||
|
>
|
||||||
|
<flux:icon.cog-6-tooth class="size-4"/>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="subTab = 'preview'"
|
||||||
|
class="tab-button"
|
||||||
|
:class="subTab === 'preview' ? 'is-active' : ''"
|
||||||
|
>
|
||||||
|
<flux:icon.eye class="size-4" />
|
||||||
|
Preview URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-col p-4 bg-transparent rounded-tl-none styled-container">
|
||||||
|
<div x-show="subTab === 'settings'">
|
||||||
|
<flux:field>
|
||||||
|
<flux:description>Enter the URL(s) to poll for data:</flux:description>
|
||||||
|
<flux:textarea
|
||||||
|
wire:model.live="polling_url"
|
||||||
placeholder="https://example.com/api"
|
placeholder="https://example.com/api"
|
||||||
class="block w-full" type="text" name="polling_url" autofocus>
|
rows="5"
|
||||||
</flux:input>
|
/>
|
||||||
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="block mt-2 w-full">
|
<flux:description>
|
||||||
|
{!! 'Hint: Supports multiple requests via line break separation. You can also use configuration variables with <a href="https://help.usetrmnl.com/en/articles/12689499-dynamic-polling-urls">Liquid syntax</a>. ' !!}
|
||||||
|
</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="subTab === 'preview'" x-cloak>
|
||||||
|
<flux:field>
|
||||||
|
<flux:description>Preview computed URLs here (readonly):</flux:description>
|
||||||
|
<flux:textarea
|
||||||
|
readonly
|
||||||
|
placeholder="Nothing to show..."
|
||||||
|
rows="5"
|
||||||
|
>
|
||||||
|
{{ $this->parsed_urls }}
|
||||||
|
</flux:textarea>
|
||||||
|
</flux:field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<flux:button icon="cloud-arrow-down" wire:click="updateData" class="w-full mt-4">
|
||||||
Fetch data now
|
Fetch data now
|
||||||
</flux:button>
|
</flux:button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
|
<flux:radio.group wire:model.live="polling_verb" label="Polling Verb" variant="segmented">
|
||||||
|
|
@ -1142,9 +971,6 @@ HTML;
|
||||||
</div>
|
</div>
|
||||||
</flux:field>
|
</flux:field>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="flex items-center gap-6 mb-4 mt-4">
|
<div class="flex items-center gap-6 mb-4 mt-4">
|
||||||
|
|
|
||||||
104
resources/views/livewire/plugins/recipes/settings.blade.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Livewire\Volt\Component;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This component contains the TRMNL Plugin Settings modal
|
||||||
|
*/
|
||||||
|
new class extends Component {
|
||||||
|
public Plugin $plugin;
|
||||||
|
public string|null $trmnlp_id = null;
|
||||||
|
public string|null $uuid = null;
|
||||||
|
public bool $alias = false;
|
||||||
|
|
||||||
|
public int $resetIndex = 0;
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->resetErrorBag();
|
||||||
|
// Reload data
|
||||||
|
$this->plugin = $this->plugin->fresh();
|
||||||
|
$this->trmnlp_id = $this->plugin->trmnlp_id;
|
||||||
|
$this->uuid = $this->plugin->uuid;
|
||||||
|
$this->alias = $this->plugin->alias ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveTrmnlpId(): void
|
||||||
|
{
|
||||||
|
abort_unless(auth()->user()->plugins->contains($this->plugin), 403);
|
||||||
|
|
||||||
|
$this->validate([
|
||||||
|
'trmnlp_id' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('plugins', 'trmnlp_id')
|
||||||
|
->where('user_id', auth()->id())
|
||||||
|
->ignore($this->plugin->id),
|
||||||
|
],
|
||||||
|
'alias' => 'boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->plugin->update([
|
||||||
|
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
|
||||||
|
'alias' => $this->alias,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Flux::modal('trmnlp-settings')->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAliasUrlProperty(): string
|
||||||
|
{
|
||||||
|
return url("/api/display/{$this->uuid}/alias");
|
||||||
|
}
|
||||||
|
};?>
|
||||||
|
|
||||||
|
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
|
||||||
|
<div wire:key="trmnlp-settings-form-{{ $resetIndex }}" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<flux:heading size="lg">Recipe Settings</flux:heading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form wire:submit="saveTrmnlpId">
|
||||||
|
<div class="grid gap-6">
|
||||||
|
{{-- <flux:input label="UUID" wire:model="uuid" readonly copyable /> --}}
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>TRMNLP Recipe ID</flux:label>
|
||||||
|
<flux:input
|
||||||
|
wire:model="trmnlp_id"
|
||||||
|
placeholder="TRMNL Recipe ID"
|
||||||
|
/>
|
||||||
|
<flux:error name="trmnlp_id" />
|
||||||
|
<flux:description>Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with <code>trmnlp</code>. </flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
<flux:field>
|
||||||
|
<flux:checkbox wire:model.live="alias" label="Enable Alias" />
|
||||||
|
<flux:description>Enable an Alias URL for this recipe. Your server does not need to be exposed to the internet, but your device must be able to reach the URL. <a href="https://help.usetrmnl.com/en/articles/10701448-alias-plugin">Docs</a></flux:description>
|
||||||
|
</flux:field>
|
||||||
|
|
||||||
|
@if($alias)
|
||||||
|
<flux:field>
|
||||||
|
<flux:label>Alias URL</flux:label>
|
||||||
|
<flux:input
|
||||||
|
value="{{ $this->aliasUrl }}"
|
||||||
|
readonly
|
||||||
|
copyable
|
||||||
|
/>
|
||||||
|
<flux:description>Copy this URL to your TRMNL Dashboard. By default, image is created for TRMNL OG; use parameter <code>?device-model=</code> to specify a device model.</flux:description>
|
||||||
|
</flux:field>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<flux:spacer/>
|
||||||
|
<flux:modal.close>
|
||||||
|
<flux:button variant="ghost">Cancel</flux:button>
|
||||||
|
</flux:modal.close>
|
||||||
|
<flux:button type="submit" variant="primary">Save</flux:button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</flux:modal>
|
||||||
|
|
@ -11,9 +11,12 @@ use Livewire\Volt\Component;
|
||||||
new class extends Component {
|
new class extends Component {
|
||||||
public ?int $assign_new_device_id = null;
|
public ?int $assign_new_device_id = null;
|
||||||
|
|
||||||
|
public ?string $timezone = null;
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->assign_new_device_id = Auth::user()->assign_new_device_id;
|
$this->assign_new_device_id = Auth::user()->assign_new_device_id;
|
||||||
|
$this->timezone = Auth::user()->timezone ?? config('app.timezone');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePreferences(): void
|
public function updatePreferences(): void
|
||||||
|
|
@ -26,6 +29,11 @@ new class extends Component {
|
||||||
->whereNull('mirror_device_id');
|
->whereNull('mirror_device_id');
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
'timezone' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::in(timezone_identifiers_list()),
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Auth::user()->update($validated);
|
Auth::user()->update($validated);
|
||||||
|
|
@ -39,6 +47,14 @@ new class extends Component {
|
||||||
|
|
||||||
<x-settings.layout heading="Preferences" subheading="Update your preferences">
|
<x-settings.layout heading="Preferences" subheading="Update your preferences">
|
||||||
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
|
<form wire:submit="updatePreferences" class="my-6 w-full space-y-6">
|
||||||
|
|
||||||
|
<flux:select wire:model="timezone" label="Timezone">
|
||||||
|
<flux:select.option value="" disabled>Select timezone...</flux:select.option>
|
||||||
|
@foreach(timezone_identifiers_list() as $tz)
|
||||||
|
<flux:select.option value="{{ $tz }}">{{ $tz }}</flux:select.option>
|
||||||
|
@endforeach
|
||||||
|
</flux:select>
|
||||||
|
|
||||||
<flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror">
|
<flux:select wire:model="assign_new_device_id" label="Auto-Joined Devices should mirror">
|
||||||
<flux:select.option value="">None</flux:select.option>
|
<flux:select.option value="">None</flux:select.option>
|
||||||
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)
|
@foreach(auth()->user()->devices->where('mirror_device_id', null) as $device)
|
||||||
|
|
|
||||||
97
resources/views/recipes/holidays-ical.blade.php
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
@props(['size' => 'full'])
|
||||||
|
@php
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
$today = Carbon::today(config('app.timezone'));
|
||||||
|
|
||||||
|
$events = collect($data['ical'] ?? [])
|
||||||
|
->map(function (array $event): array {
|
||||||
|
try {
|
||||||
|
$start = isset($event['DTSTART'])
|
||||||
|
? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone'))
|
||||||
|
: null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$start = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$end = isset($event['DTEND'])
|
||||||
|
? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone'))
|
||||||
|
: null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$end = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'summary' => $event['SUMMARY'] ?? 'Untitled event',
|
||||||
|
'location' => $event['LOCATION'] ?? '—',
|
||||||
|
'start' => $start,
|
||||||
|
'end' => $end,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter(fn ($event) =>
|
||||||
|
$event['start'] &&
|
||||||
|
(
|
||||||
|
$event['start']->greaterThanOrEqualTo($today) ||
|
||||||
|
($event['end'] && $event['end']->greaterThanOrEqualTo($today))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->sortBy('start')
|
||||||
|
->take($size === 'quadrant' ? 5 : 8)
|
||||||
|
->values();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
|
||||||
|
<x-trmnl::view size="{{$size}}">
|
||||||
|
<x-trmnl::layout class="layout--col gap--small">
|
||||||
|
<x-trmnl::table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<x-trmnl::title>Date</x-trmnl::title>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<x-trmnl::title>Time</x-trmnl::title>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<x-trmnl::title>Event</x-trmnl::title>
|
||||||
|
</th>
|
||||||
|
<th>
|
||||||
|
<x-trmnl::title>Location</x-trmnl::title>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($events as $event)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<x-trmnl::label>{{ $event['start']?->format('D, M j') }}</x-trmnl::label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<x-trmnl::label>
|
||||||
|
{{ $event['start']?->format('H:i') }}
|
||||||
|
@if($event['end'])
|
||||||
|
– {{ $event['end']->format('H:i') }}
|
||||||
|
@endif
|
||||||
|
</x-trmnl::label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<x-trmnl::label variant="primary">{{ $event['summary'] }}</x-trmnl::label>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<x-trmnl::label variant="inverted">{{ $event['location'] ?? '—' }}</x-trmnl::label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr>
|
||||||
|
<td colspan="4">
|
||||||
|
<x-trmnl::label>No events available</x-trmnl::label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</x-trmnl::table>
|
||||||
|
</x-trmnl::layout>
|
||||||
|
|
||||||
|
<x-trmnl::title-bar title="Public Holidays" instance="updated: {{ now()->format('M j, H:i') }}"/>
|
||||||
|
</x-trmnl::view>
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
<x-trmnl::view size="{{ $size }}">
|
<x-trmnl::view size="{{ $size }}">
|
||||||
<x-trmnl::layout>
|
<x-trmnl::layout>
|
||||||
<x-trmnl::layout class="layout--col">
|
<x-trmnl::layout class="layout--col">
|
||||||
<div class="b-h-gray-1">{{$data[0]['a']}}</div>
|
<div class="b-h-gray-1">{{$data['data'][0]['a'] ?? ''}}</div>
|
||||||
@if (strlen($data[0]['q']) < 300 && $size != 'quadrant')
|
@if (strlen($data['data'][0]['q'] ?? '') < 300 && $size != 'quadrant')
|
||||||
<p class="value">{{ $data[0]['q'] }}</p>
|
<p class="value">{{ $data['data'][0]['q'] ?? '' }}</p>
|
||||||
@else
|
@else
|
||||||
<p class="value--small">{{ $data[0]['q'] }}</p>
|
<p class="value--small">{{ $data['data'][0]['q'] ?? '' }}</p>
|
||||||
@endif
|
@endif
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
</x-trmnl::layout>
|
</x-trmnl::layout>
|
||||||
|
|
|
||||||
206
routes/api.php
|
|
@ -18,7 +18,7 @@ use Illuminate\Support\Str;
|
||||||
Route::get('/display', function (Request $request) {
|
Route::get('/display', function (Request $request) {
|
||||||
$mac_address = $request->header('id');
|
$mac_address = $request->header('id');
|
||||||
$access_token = $request->header('access-token');
|
$access_token = $request->header('access-token');
|
||||||
$device = Device::where('mac_address', $mac_address)
|
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
|
||||||
->where('api_key', $access_token)
|
->where('api_key', $access_token)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) {
|
||||||
if ($auto_assign_user) {
|
if ($auto_assign_user) {
|
||||||
// Create a new device and assign it to this user
|
// Create a new device and assign it to this user
|
||||||
$device = Device::create([
|
$device = Device::create([
|
||||||
'mac_address' => $mac_address,
|
'mac_address' => mb_strtoupper($mac_address ?? ''),
|
||||||
'api_key' => $access_token,
|
'api_key' => $access_token,
|
||||||
'user_id' => $auto_assign_user->id,
|
'user_id' => $auto_assign_user->id,
|
||||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||||
|
|
@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) {
|
||||||
// Check and update stale data if needed
|
// Check and update stale data if needed
|
||||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||||
$plugin->updateDataPayload();
|
$plugin->updateDataPayload();
|
||||||
$markup = $plugin->render(device: $device);
|
try {
|
||||||
|
$markup = $plugin->render(device: $device);
|
||||||
|
|
||||||
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
|
||||||
|
// Generate error display
|
||||||
|
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name);
|
||||||
|
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$plugin->refresh();
|
$plugin->refresh();
|
||||||
|
|
@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$markup = $playlistItem->render(device: $device);
|
try {
|
||||||
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
$markup = $playlistItem->render(device: $device);
|
||||||
|
GenerateScreenJob::dispatchSync($device->id, null, $markup);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage());
|
||||||
|
// For mashups, show error for the first plugin or a generic error
|
||||||
|
$firstPlugin = $plugins->first();
|
||||||
|
$pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe';
|
||||||
|
$errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName);
|
||||||
|
$device->update(['current_screen_image' => $errorImageUuid]);
|
||||||
|
}
|
||||||
|
|
||||||
$device->refresh();
|
$device->refresh();
|
||||||
|
|
||||||
|
|
@ -204,7 +220,7 @@ Route::get('/setup', function (Request $request) {
|
||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$device = Device::where('mac_address', $mac_address)->first();
|
$device = Device::where('mac_address', mb_strtoupper($mac_address))->first();
|
||||||
|
|
||||||
if (! $device) {
|
if (! $device) {
|
||||||
// Check if there's a user with assign_new_devices enabled
|
// Check if there's a user with assign_new_devices enabled
|
||||||
|
|
@ -219,7 +235,7 @@ Route::get('/setup', function (Request $request) {
|
||||||
|
|
||||||
// Create a new device and assign it to this user
|
// Create a new device and assign it to this user
|
||||||
$device = Device::create([
|
$device = Device::create([
|
||||||
'mac_address' => $mac_address,
|
'mac_address' => mb_strtoupper($mac_address),
|
||||||
'api_key' => Str::random(22),
|
'api_key' => Str::random(22),
|
||||||
'user_id' => $auto_assign_user->id,
|
'user_id' => $auto_assign_user->id,
|
||||||
'name' => "{$auto_assign_user->name}'s TRMNL",
|
'name' => "{$auto_assign_user->name}'s TRMNL",
|
||||||
|
|
@ -345,7 +361,7 @@ Route::post('/display/update', function (Request $request) {
|
||||||
Route::post('/screens', function (Request $request) {
|
Route::post('/screens', function (Request $request) {
|
||||||
$mac_address = $request->header('id');
|
$mac_address = $request->header('id');
|
||||||
$access_token = $request->header('access-token');
|
$access_token = $request->header('access-token');
|
||||||
$device = Device::where('mac_address', $mac_address)
|
$device = Device::where('mac_address', mb_strtoupper($mac_address ?? ''))
|
||||||
->where('api_key', $access_token)
|
->where('api_key', $access_token)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|
@ -533,6 +549,91 @@ Route::post('custom_plugins/{plugin_uuid}', function (string $plugin_uuid) {
|
||||||
return response()->json(['message' => 'Data updated successfully']);
|
return response()->json(['message' => 'Data updated successfully']);
|
||||||
})->name('api.custom_plugins.webhook');
|
})->name('api.custom_plugins.webhook');
|
||||||
|
|
||||||
|
Route::post('plugin_settings/{uuid}/image', function (Request $request, string $uuid) {
|
||||||
|
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
|
||||||
|
|
||||||
|
// Check if plugin is image_webhook type
|
||||||
|
if ($plugin->plugin_type !== 'image_webhook') {
|
||||||
|
return response()->json(['error' => 'Plugin is not an image webhook plugin'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept image from either multipart form or raw binary
|
||||||
|
$image = null;
|
||||||
|
$extension = null;
|
||||||
|
|
||||||
|
if ($request->hasFile('image')) {
|
||||||
|
$file = $request->file('image');
|
||||||
|
$extension = mb_strtolower($file->getClientOriginalExtension());
|
||||||
|
$image = $file->get();
|
||||||
|
} elseif ($request->has('image')) {
|
||||||
|
// Base64 encoded image
|
||||||
|
$imageData = $request->input('image');
|
||||||
|
if (preg_match('/^data:image\/(\w+);base64,/', $imageData, $matches)) {
|
||||||
|
$extension = mb_strtolower($matches[1]);
|
||||||
|
$image = base64_decode(mb_substr($imageData, mb_strpos($imageData, ',') + 1));
|
||||||
|
} else {
|
||||||
|
return response()->json(['error' => 'Invalid image format. Expected base64 data URI.'], 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try raw binary
|
||||||
|
$image = $request->getContent();
|
||||||
|
$contentType = $request->header('Content-Type', '');
|
||||||
|
$trimmedContent = mb_trim($image);
|
||||||
|
|
||||||
|
// Check if content is empty or just empty JSON
|
||||||
|
if (empty($image) || $trimmedContent === '' || $trimmedContent === '{}') {
|
||||||
|
return response()->json(['error' => 'No image data provided'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a JSON request without image field, return error
|
||||||
|
if (str_contains($contentType, 'application/json')) {
|
||||||
|
return response()->json(['error' => 'No image data provided'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect image type from content
|
||||||
|
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||||
|
$mimeType = finfo_buffer($finfo, $image);
|
||||||
|
finfo_close($finfo);
|
||||||
|
|
||||||
|
$extension = match ($mimeType) {
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/bmp' => 'bmp',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (! $extension) {
|
||||||
|
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate extension
|
||||||
|
$allowedExtensions = ['png', 'bmp'];
|
||||||
|
if (! in_array($extension, $allowedExtensions)) {
|
||||||
|
return response()->json(['error' => 'Unsupported image format. Expected PNG or BMP.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new UUID for each image upload to prevent device caching
|
||||||
|
$imageUuid = Str::uuid()->toString();
|
||||||
|
$filename = $imageUuid.'.'.$extension;
|
||||||
|
$path = 'images/generated/'.$filename;
|
||||||
|
|
||||||
|
// Save image to storage
|
||||||
|
Storage::disk('public')->put($path, $image);
|
||||||
|
|
||||||
|
// Update plugin's current_image field with the new UUID
|
||||||
|
$plugin->update([
|
||||||
|
'current_image' => $imageUuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clean up old images
|
||||||
|
ImageGenerationService::cleanupFolder();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Image uploaded successfully',
|
||||||
|
'image_url' => url('storage/'.$path),
|
||||||
|
]);
|
||||||
|
})->name('api.plugin_settings.image');
|
||||||
|
|
||||||
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
Route::get('plugin_settings/{trmnlp_id}/archive', function (Request $request, string $trmnlp_id) {
|
||||||
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
|
if (! $trmnlp_id || mb_trim($trmnlp_id) === '') {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
@ -577,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
})->middleware('auth:sanctum');
|
})->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
|
||||||
|
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
|
||||||
|
|
||||||
|
// Check if alias is active
|
||||||
|
if (! $plugin->alias) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Alias is not active for this plugin',
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device model name from query parameter, default to 'og_png'
|
||||||
|
$deviceModelName = $request->query('device-model', 'og_png');
|
||||||
|
$deviceModel = DeviceModel::where('name', $deviceModelName)->first();
|
||||||
|
|
||||||
|
if (! $deviceModel) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => "Device model '{$deviceModelName}' not found",
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we can use cached image (only for og_png and if data is not stale)
|
||||||
|
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
|
||||||
|
|
||||||
|
if ($useCache) {
|
||||||
|
// Return cached image
|
||||||
|
$imageUuid = $plugin->current_image;
|
||||||
|
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||||
|
$imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension;
|
||||||
|
|
||||||
|
// Check if image exists, otherwise fall back to generation
|
||||||
|
if (Storage::disk('public')->exists($imagePath)) {
|
||||||
|
return response()->file(Storage::disk('public')->path($imagePath), [
|
||||||
|
'Content-Type' => $deviceModel->mime_type,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new image
|
||||||
|
try {
|
||||||
|
// Update data if needed
|
||||||
|
if ($plugin->isDataStale()) {
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
$plugin->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load device model with palette relationship
|
||||||
|
$deviceModel->load('palette');
|
||||||
|
|
||||||
|
// Create a virtual device for rendering (Plugin::render needs a Device object)
|
||||||
|
$virtualDevice = new Device();
|
||||||
|
$virtualDevice->setRelation('deviceModel', $deviceModel);
|
||||||
|
$virtualDevice->setRelation('user', $plugin->user);
|
||||||
|
$virtualDevice->setRelation('palette', $deviceModel->palette);
|
||||||
|
|
||||||
|
// Render the plugin markup
|
||||||
|
$markup = $plugin->render(device: $virtualDevice);
|
||||||
|
|
||||||
|
// Generate image using the new method that doesn't require a device
|
||||||
|
$imageUuid = ImageGenerationService::generateImageFromModel(
|
||||||
|
markup: $markup,
|
||||||
|
deviceModel: $deviceModel,
|
||||||
|
user: $plugin->user,
|
||||||
|
palette: $deviceModel->palette
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update plugin cache if using og_png
|
||||||
|
if ($deviceModelName === 'og_png') {
|
||||||
|
$plugin->update(['current_image' => $imageUuid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the generated image
|
||||||
|
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||||
|
$imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension);
|
||||||
|
|
||||||
|
return response()->file($imagePath, [
|
||||||
|
'Content-Type' => $deviceModel->mime_type,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Failed to generate image',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
})->name('api.display.alias');
|
||||||
|
|
|
||||||
|
|
@ -24,12 +24,15 @@ Route::middleware(['auth'])->group(function () {
|
||||||
Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
|
Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
|
||||||
|
|
||||||
Volt::route('/device-models', 'device-models.index')->name('device-models.index');
|
Volt::route('/device-models', 'device-models.index')->name('device-models.index');
|
||||||
|
Volt::route('/device-palettes', 'device-palettes.index')->name('device-palettes.index');
|
||||||
|
|
||||||
Volt::route('plugins', 'plugins.index')->name('plugins.index');
|
Volt::route('plugins', 'plugins.index')->name('plugins.index');
|
||||||
|
|
||||||
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
||||||
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
Volt::route('plugins/markup', 'plugins.markup')->name('plugins.markup');
|
||||||
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
|
Volt::route('plugins/api', 'plugins.api')->name('plugins.api');
|
||||||
|
Volt::route('plugins/image-webhook', 'plugins.image-webhook')->name('plugins.image-webhook');
|
||||||
|
Volt::route('plugins/image-webhook/{plugin}', 'plugins.image-webhook-instance')->name('plugins.image-webhook-instance');
|
||||||
Volt::route('playlists', 'playlists.index')->name('playlists.index');
|
Volt::route('playlists', 'playlists.index')->name('playlists.index');
|
||||||
|
|
||||||
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
Route::get('plugin_settings/{trmnlp_id}/edit', function (Request $request, string $trmnlp_id) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use App\Models\Playlist;
|
||||||
use App\Models\PlaylistItem;
|
use App\Models\PlaylistItem;
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\ImageGenerationService;
|
||||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
@ -954,3 +955,232 @@ test('setup endpoint handles non-existent device model gracefully', function ():
|
||||||
expect($device)->not->toBeNull()
|
expect($device)->not->toBeNull()
|
||||||
->and($device->device_model_id)->toBeNull();
|
->and($device->device_model_id)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setup endpoint matches MAC address case-insensitively', function (): void {
|
||||||
|
// Create device with lowercase MAC address
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => 'a1:b2:c3:d4:e5:f6',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'friendly_id' => 'test-device',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Request with uppercase MAC address should still match
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'id' => 'A1:B2:C3:D4:E5:F6',
|
||||||
|
])->get('/api/setup');
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJson([
|
||||||
|
'status' => 200,
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'friendly_id' => 'test-device',
|
||||||
|
'message' => 'Welcome to TRMNL BYOS',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display endpoint matches MAC address case-insensitively', function (): void {
|
||||||
|
// Create device with lowercase MAC address
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => 'a1:b2:c3:d4:e5:f6',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'current_screen_image' => 'test-image',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Request with uppercase MAC address should still match
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'id' => 'A1:B2:C3:D4:E5:F6',
|
||||||
|
'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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('screens endpoint matches MAC address case-insensitively', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
// Create device with uppercase MAC address
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => 'A1:B2:C3:D4:E5:F6',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Request with lowercase MAC address should still match
|
||||||
|
$response = $this->withHeaders([
|
||||||
|
'id' => 'a1:b2:c3:d4:e5:f6',
|
||||||
|
'access-token' => $device->api_key,
|
||||||
|
])->post('/api/screens', [
|
||||||
|
'image' => [
|
||||||
|
'content' => '<div>Test content</div>',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
Queue::assertPushed(GenerateScreenJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display endpoint handles plugin rendering errors gracefully', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => '00:11:22:33:44:55',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'proxy_cloud' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a plugin with Blade markup that will cause an exception when accessing data[0]
|
||||||
|
// when data is not an array or doesn't have index 0
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'name' => 'Broken Recipe',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||||
|
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail if data[0] doesn't exist
|
||||||
|
'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2), // Make it stale
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$playlist = Playlist::factory()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'name' => 'test_playlist',
|
||||||
|
'is_active' => true,
|
||||||
|
'weekdays' => null,
|
||||||
|
'active_from' => null,
|
||||||
|
'active_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
PlaylistItem::factory()->create([
|
||||||
|
'playlist_id' => $playlist->id,
|
||||||
|
'plugin_id' => $plugin->id,
|
||||||
|
'order' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
'last_displayed_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
// Verify error screen was generated and set on device
|
||||||
|
$device->refresh();
|
||||||
|
expect($device->current_screen_image)->not->toBeNull();
|
||||||
|
|
||||||
|
// Verify the error image exists
|
||||||
|
$errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png");
|
||||||
|
// The TrmnlPipeline is faked, so we just verify the UUID was set
|
||||||
|
expect($device->current_screen_image)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display endpoint handles mashup rendering errors gracefully', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
|
||||||
|
$device = Device::factory()->create([
|
||||||
|
'mac_address' => '00:11:22:33:44:55',
|
||||||
|
'api_key' => 'test-api-key',
|
||||||
|
'proxy_cloud' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create plugins for mashup, one with invalid markup
|
||||||
|
$plugin1 = Plugin::factory()->create([
|
||||||
|
'name' => 'Working Plugin',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'render_markup_view' => 'trmnl',
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2),
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin2 = Plugin::factory()->create([
|
||||||
|
'name' => 'Broken Plugin',
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => null,
|
||||||
|
'data_stale_minutes' => 1,
|
||||||
|
'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access
|
||||||
|
'render_markup' => '<div>{{ $data[0]["invalid"] }}</div>', // This will fail
|
||||||
|
'data_payload' => ['error' => 'Failed to fetch data'],
|
||||||
|
'data_payload_updated_at' => now()->subMinutes(2),
|
||||||
|
'current_image' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$playlist = Playlist::factory()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'name' => 'test_playlist',
|
||||||
|
'is_active' => true,
|
||||||
|
'weekdays' => null,
|
||||||
|
'active_from' => null,
|
||||||
|
'active_until' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create mashup playlist item
|
||||||
|
$playlistItem = PlaylistItem::createMashup(
|
||||||
|
$playlist,
|
||||||
|
'1Lx1R',
|
||||||
|
[$plugin1->id, $plugin2->id],
|
||||||
|
'Test Mashup',
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
$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();
|
||||||
|
|
||||||
|
// Verify error screen was generated and set on device
|
||||||
|
$device->refresh();
|
||||||
|
expect($device->current_screen_image)->not->toBeNull();
|
||||||
|
|
||||||
|
// Verify the error image UUID was set
|
||||||
|
expect($device->current_screen_image)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDefaultScreenImage creates error screen with plugin name', function (): void {
|
||||||
|
TrmnlPipeline::fake();
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->makeDirectory('/images/generated');
|
||||||
|
|
||||||
|
$device = Device::factory()->create();
|
||||||
|
|
||||||
|
$errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name');
|
||||||
|
|
||||||
|
expect($errorUuid)->not->toBeEmpty();
|
||||||
|
|
||||||
|
// Verify the error image path would be created
|
||||||
|
$errorPath = "images/generated/{$errorUuid}.png";
|
||||||
|
// Since TrmnlPipeline is faked, we just verify the UUID was generated
|
||||||
|
expect($errorUuid)->toBeString();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateDefaultScreenImage throws exception for invalid error image type', function (): void {
|
||||||
|
$device = Device::factory()->create();
|
||||||
|
|
||||||
|
expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type'))
|
||||||
|
->toThrow(InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void {
|
||||||
|
$device = new Device();
|
||||||
|
$device->deviceModel = null;
|
||||||
|
|
||||||
|
$result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error');
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
|
|
||||||
196
tests/Feature/Api/ImageWebhookTest.php
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Storage::fake('public');
|
||||||
|
Storage::disk('public')->makeDirectory('/images/generated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can upload image to image webhook plugin via multipart form', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||||
|
|
||||||
|
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||||
|
'image' => $image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'message',
|
||||||
|
'image_url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->refresh();
|
||||||
|
expect($plugin->current_image)
|
||||||
|
->not->toBeNull()
|
||||||
|
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||||
|
|
||||||
|
// File should exist with the new UUID
|
||||||
|
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||||
|
|
||||||
|
// Image URL should contain the new UUID
|
||||||
|
expect($response->json('image_url'))
|
||||||
|
->toContain($plugin->current_image);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can upload image to image webhook plugin via raw binary', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a simple PNG image binary
|
||||||
|
$pngData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==');
|
||||||
|
|
||||||
|
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'image/png',
|
||||||
|
], $pngData);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'message',
|
||||||
|
'image_url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->refresh();
|
||||||
|
expect($plugin->current_image)
|
||||||
|
->not->toBeNull()
|
||||||
|
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||||
|
|
||||||
|
// File should exist with the new UUID
|
||||||
|
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||||
|
|
||||||
|
// Image URL should contain the new UUID
|
||||||
|
expect($response->json('image_url'))
|
||||||
|
->toContain($plugin->current_image);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can upload image to image webhook plugin via base64 data URI', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a simple PNG image as base64 data URI
|
||||||
|
$base64Image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||||
|
'image' => $base64Image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk()
|
||||||
|
->assertJsonStructure([
|
||||||
|
'message',
|
||||||
|
'image_url',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->refresh();
|
||||||
|
expect($plugin->current_image)
|
||||||
|
->not->toBeNull()
|
||||||
|
->not->toBe($plugin->uuid); // Should be a new UUID, not the plugin's UUID
|
||||||
|
|
||||||
|
// File should exist with the new UUID
|
||||||
|
Storage::disk('public')->assertExists("images/generated/{$plugin->current_image}.png");
|
||||||
|
|
||||||
|
// Image URL should contain the new UUID
|
||||||
|
expect($response->json('image_url'))
|
||||||
|
->toContain($plugin->current_image);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for non-image-webhook plugin', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plugin_type' => 'recipe',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||||
|
|
||||||
|
$response = $this->post("/api/plugin_settings/{$plugin->uuid}/image", [
|
||||||
|
'image' => $image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson(['error' => 'Plugin is not an image webhook plugin']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 404 for non-existent plugin', function (): void {
|
||||||
|
$image = UploadedFile::fake()->image('test.png', 800, 480);
|
||||||
|
|
||||||
|
$response = $this->post('/api/plugin_settings/'.Str::uuid().'/image', [
|
||||||
|
'image' => $image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for unsupported image format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a fake GIF file (not supported)
|
||||||
|
$gifData = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
||||||
|
|
||||||
|
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'image/gif',
|
||||||
|
], $gifData);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 for JPG image format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create a fake JPG file (not supported)
|
||||||
|
$jpgData = base64_decode('/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA/8A8A');
|
||||||
|
|
||||||
|
$response = $this->call('POST', "/api/plugin_settings/{$plugin->uuid}/image", [], [], [], [
|
||||||
|
'CONTENT_TYPE' => 'image/jpeg',
|
||||||
|
], $jpgData);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson(['error' => 'Unsupported image format. Expected PNG or BMP.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 400 when no image data provided', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->postJson("/api/plugin_settings/{$plugin->uuid}/image", []);
|
||||||
|
|
||||||
|
$response->assertStatus(400)
|
||||||
|
->assertJson(['error' => 'No image data provided']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image webhook plugin isDataStale returns false', function (): void {
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create();
|
||||||
|
|
||||||
|
expect($plugin->isDataStale())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image webhook plugin factory creates correct plugin type', function (): void {
|
||||||
|
$plugin = Plugin::factory()->imageWebhook()->create();
|
||||||
|
|
||||||
|
expect($plugin)
|
||||||
|
->plugin_type->toBe('image_webhook')
|
||||||
|
->data_strategy->toBe('static');
|
||||||
|
});
|
||||||
|
|
@ -324,6 +324,30 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
|
||||||
expect($plugin->current_image)->toBe('test-uuid');
|
expect($plugin->current_image)->toBe('test-uuid');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cache is reset when plugin markup changes', function (): void {
|
||||||
|
// Create a plugin with cached image
|
||||||
|
$plugin = App\Models\Plugin::factory()->create([
|
||||||
|
'current_image' => 'cached-uuid',
|
||||||
|
'render_markup' => '<div>Original markup</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create devices with standard dimensions (cacheable)
|
||||||
|
Device::factory()->count(2)->create([
|
||||||
|
'width' => 800,
|
||||||
|
'height' => 480,
|
||||||
|
'rotate' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update the plugin markup
|
||||||
|
$plugin->update([
|
||||||
|
'render_markup' => '<div>Updated markup</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Assert cache was reset when markup changed
|
||||||
|
$plugin->refresh();
|
||||||
|
expect($plugin->current_image)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('determines correct image format from device model', function (): void {
|
it('determines correct image format from device model', function (): void {
|
||||||
// Test BMP format detection
|
// Test BMP format detection
|
||||||
$bmpModel = DeviceModel::factory()->create([
|
$bmpModel = DeviceModel::factory()->create([
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,13 @@ uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function (): void {
|
beforeEach(function (): void {
|
||||||
DeviceModel::truncate();
|
DeviceModel::truncate();
|
||||||
|
|
||||||
|
// Mock palettes API to return empty array by default
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response([
|
||||||
|
'data' => [],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetch device models job can be dispatched', function (): void {
|
test('fetch device models job can be dispatched', function (): void {
|
||||||
|
|
@ -21,6 +28,7 @@ test('fetch device models job can be dispatched', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles successful api response', function (): void {
|
test('fetch device models job handles successful api response', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -36,12 +44,17 @@ test('fetch device models job handles successful api response', function (): voi
|
||||||
'mime_type' => 'image/png',
|
'mime_type' => 'image/png',
|
||||||
'offset_x' => 0,
|
'offset_x' => 0,
|
||||||
'offset_y' => 0,
|
'offset_y' => 0,
|
||||||
|
'kind' => 'trmnl',
|
||||||
'published_at' => '2023-01-01T00:00:00Z',
|
'published_at' => '2023-01-01T00:00:00Z',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 1]);
|
->with('Successfully fetched and updated device models', ['count' => 1]);
|
||||||
|
|
@ -62,11 +75,13 @@ test('fetch device models job handles successful api response', function (): voi
|
||||||
expect($deviceModel->mime_type)->toBe('image/png');
|
expect($deviceModel->mime_type)->toBe('image/png');
|
||||||
expect($deviceModel->offset_x)->toBe(0);
|
expect($deviceModel->offset_x)->toBe(0);
|
||||||
expect($deviceModel->offset_y)->toBe(0);
|
expect($deviceModel->offset_y)->toBe(0);
|
||||||
|
// expect($deviceModel->kind)->toBe('trmnl');
|
||||||
expect($deviceModel->source)->toBe('api');
|
expect($deviceModel->source)->toBe('api');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('fetch device models job handles multiple device models', function (): void {
|
test('fetch device models job handles multiple device models', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -103,6 +118,10 @@ test('fetch device models job handles multiple device models', function (): void
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 2]);
|
->with('Successfully fetched and updated device models', ['count' => 2]);
|
||||||
|
|
@ -116,11 +135,16 @@ test('fetch device models job handles multiple device models', function (): void
|
||||||
|
|
||||||
test('fetch device models job handles empty data array', function (): void {
|
test('fetch device models job handles empty data array', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [],
|
'data' => [],
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 0]);
|
->with('Successfully fetched and updated device models', ['count' => 0]);
|
||||||
|
|
@ -133,11 +157,16 @@ test('fetch device models job handles empty data array', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles missing data field', function (): void {
|
test('fetch device models job handles missing data field', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'message' => 'No data available',
|
'message' => 'No data available',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 0]);
|
->with('Successfully fetched and updated device models', ['count' => 0]);
|
||||||
|
|
@ -150,11 +179,16 @@ test('fetch device models job handles missing data field', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles non-array data', function (): void {
|
test('fetch device models job handles non-array data', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => 'invalid-data',
|
'data' => 'invalid-data',
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('error')
|
Log::shouldReceive('error')
|
||||||
->once()
|
->once()
|
||||||
->with('Invalid response format from device models API', Mockery::type('array'));
|
->with('Invalid response format from device models API', Mockery::type('array'));
|
||||||
|
|
@ -167,11 +201,16 @@ test('fetch device models job handles non-array data', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles api failure', function (): void {
|
test('fetch device models job handles api failure', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'error' => 'Internal Server Error',
|
'error' => 'Internal Server Error',
|
||||||
], 500),
|
], 500),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('error')
|
Log::shouldReceive('error')
|
||||||
->once()
|
->once()
|
||||||
->with('Failed to fetch device models from API', [
|
->with('Failed to fetch device models from API', [
|
||||||
|
|
@ -187,11 +226,16 @@ test('fetch device models job handles api failure', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles network exception', function (): void {
|
test('fetch device models job handles network exception', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => function (): void {
|
'usetrmnl.com/api/models' => function (): void {
|
||||||
throw new Exception('Network connection failed');
|
throw new Exception('Network connection failed');
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('error')
|
Log::shouldReceive('error')
|
||||||
->once()
|
->once()
|
||||||
->with('Exception occurred while fetching device models', Mockery::type('array'));
|
->with('Exception occurred while fetching device models', Mockery::type('array'));
|
||||||
|
|
@ -204,6 +248,7 @@ test('fetch device models job handles network exception', function (): void {
|
||||||
|
|
||||||
test('fetch device models job handles device model with missing name', function (): void {
|
test('fetch device models job handles device model with missing name', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -214,6 +259,10 @@ test('fetch device models job handles device model with missing name', function
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('warning')
|
Log::shouldReceive('warning')
|
||||||
->once()
|
->once()
|
||||||
->with('Device model data missing name field', Mockery::type('array'));
|
->with('Device model data missing name field', Mockery::type('array'));
|
||||||
|
|
@ -230,6 +279,7 @@ test('fetch device models job handles device model with missing name', function
|
||||||
|
|
||||||
test('fetch device models job handles device model with partial data', function (): void {
|
test('fetch device models job handles device model with partial data', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -240,6 +290,10 @@ test('fetch device models job handles device model with partial data', function
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 1]);
|
->with('Successfully fetched and updated device models', ['count' => 1]);
|
||||||
|
|
@ -260,6 +314,7 @@ test('fetch device models job handles device model with partial data', function
|
||||||
expect($deviceModel->mime_type)->toBe('');
|
expect($deviceModel->mime_type)->toBe('');
|
||||||
expect($deviceModel->offset_x)->toBe(0);
|
expect($deviceModel->offset_x)->toBe(0);
|
||||||
expect($deviceModel->offset_y)->toBe(0);
|
expect($deviceModel->offset_y)->toBe(0);
|
||||||
|
expect($deviceModel->kind)->toBeNull();
|
||||||
expect($deviceModel->source)->toBe('api');
|
expect($deviceModel->source)->toBe('api');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -273,6 +328,7 @@ test('fetch device models job updates existing device model', function (): void
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -294,6 +350,10 @@ test('fetch device models job updates existing device model', function (): void
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('info')
|
Log::shouldReceive('info')
|
||||||
->once()
|
->once()
|
||||||
->with('Successfully fetched and updated device models', ['count' => 1]);
|
->with('Successfully fetched and updated device models', ['count' => 1]);
|
||||||
|
|
@ -311,6 +371,7 @@ test('fetch device models job updates existing device model', function (): void
|
||||||
|
|
||||||
test('fetch device models job handles processing exception for individual model', function (): void {
|
test('fetch device models job handles processing exception for individual model', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
'usetrmnl.com/api/palettes' => Http::response(['data' => []], 200),
|
||||||
'usetrmnl.com/api/models' => Http::response([
|
'usetrmnl.com/api/models' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
[
|
[
|
||||||
|
|
@ -327,6 +388,10 @@ test('fetch device models job handles processing exception for individual model'
|
||||||
], 200),
|
], 200),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
Log::shouldReceive('info')
|
||||||
|
->once()
|
||||||
|
->with('Successfully fetched and updated palettes', ['count' => 0]);
|
||||||
|
|
||||||
Log::shouldReceive('warning')
|
Log::shouldReceive('warning')
|
||||||
->once()
|
->once()
|
||||||
->with('Device model data missing name field', Mockery::type('array'));
|
->with('Device model data missing name field', Mockery::type('array'));
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void {
|
||||||
$component->assertSee('testuser');
|
$component->assertSee('testuser');
|
||||||
$component->assertSee('A test plugin');
|
$component->assertSee('A test plugin');
|
||||||
$component->assertSee('MIT');
|
$component->assertSee('MIT');
|
||||||
|
$component->assertSee('Preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides preview button when screenshot_url is missing', function (): void {
|
||||||
|
// Clear cache first to ensure fresh data
|
||||||
|
Cache::forget('catalog_plugins');
|
||||||
|
|
||||||
|
// Mock the HTTP response for the catalog URL without screenshot_url
|
||||||
|
$catalogData = [
|
||||||
|
'test-plugin' => [
|
||||||
|
'name' => 'Test Plugin Without Screenshot',
|
||||||
|
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||||
|
'author_bio' => [
|
||||||
|
'description' => 'A test plugin',
|
||||||
|
],
|
||||||
|
'license' => 'MIT',
|
||||||
|
'trmnlp' => [
|
||||||
|
'zip_url' => 'https://example.com/plugin.zip',
|
||||||
|
],
|
||||||
|
'byos' => [
|
||||||
|
'byos_laravel' => [
|
||||||
|
'compatibility' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logo_url' => 'https://example.com/logo.png',
|
||||||
|
'screenshot_url' => null,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$yamlContent = Yaml::dump($catalogData);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.index')
|
||||||
|
->assertSee('Test Plugin Without Screenshot')
|
||||||
|
->assertDontSeeHtml('variant="subtle" icon="eye"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when plugin not found', function (): void {
|
it('shows error when plugin not found', function (): void {
|
||||||
|
|
@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void {
|
||||||
$component->assertHasErrors();
|
$component->assertHasErrors();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('can preview a plugin', function (): void {
|
||||||
|
// Clear cache first to ensure fresh data
|
||||||
|
Cache::forget('catalog_plugins');
|
||||||
|
|
||||||
|
// Mock the HTTP response for the catalog URL
|
||||||
|
$catalogData = [
|
||||||
|
'test-plugin' => [
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'author' => ['name' => 'Test Author', 'github' => 'testuser'],
|
||||||
|
'author_bio' => [
|
||||||
|
'description' => 'A test plugin description',
|
||||||
|
],
|
||||||
|
'license' => 'MIT',
|
||||||
|
'trmnlp' => [
|
||||||
|
'zip_url' => 'https://example.com/plugin.zip',
|
||||||
|
],
|
||||||
|
'byos' => [
|
||||||
|
'byos_laravel' => [
|
||||||
|
'compatibility' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logo_url' => 'https://example.com/logo.png',
|
||||||
|
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$yamlContent = Yaml::dump($catalogData);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
config('app.catalog_url') => Http::response($yamlContent, 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.index')
|
||||||
|
->assertSee('Test Plugin')
|
||||||
|
->call('previewPlugin', 'test-plugin')
|
||||||
|
->assertSet('previewingPlugin', 'test-plugin')
|
||||||
|
->assertSet('previewData.name', 'Test Plugin')
|
||||||
|
->assertSee('Preview Test Plugin')
|
||||||
|
->assertSee('A test plugin description');
|
||||||
|
});
|
||||||
|
|
|
||||||
124
tests/Feature/Livewire/Plugins/ConfigModalTest.php
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('config modal correctly loads multi_string defaults into UI boxes', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration_template' => [
|
||||||
|
'custom_fields' => [[
|
||||||
|
'keyname' => 'tags',
|
||||||
|
'field_type' => 'multi_string',
|
||||||
|
'name' => 'Reading Days',
|
||||||
|
'default' => 'alpha,beta',
|
||||||
|
]]
|
||||||
|
],
|
||||||
|
'configuration' => ['tags' => 'alpha,beta']
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.config-modal', ['plugin' => $plugin])
|
||||||
|
->assertSet('multiValues.tags', ['alpha', 'beta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config modal validates against commas in multi_string boxes', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration_template' => [
|
||||||
|
'custom_fields' => [[
|
||||||
|
'keyname' => 'tags',
|
||||||
|
'field_type' => 'multi_string',
|
||||||
|
'name' => 'Reading Days',
|
||||||
|
]]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.config-modal', ['plugin' => $plugin])
|
||||||
|
->set('multiValues.tags.0', 'no,commas,allowed')
|
||||||
|
->call('saveConfiguration')
|
||||||
|
->assertHasErrors(['multiValues.tags.0' => 'regex']);
|
||||||
|
|
||||||
|
// Assert DB remains unchanged
|
||||||
|
expect($plugin->fresh()->configuration['tags'] ?? '')->not->toBe('no,commas,allowed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config modal merges multi_string boxes into a single CSV string on save', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration_template' => [
|
||||||
|
'custom_fields' => [[
|
||||||
|
'keyname' => 'items',
|
||||||
|
'field_type' => 'multi_string',
|
||||||
|
'name' => 'Reading Days',
|
||||||
|
]]
|
||||||
|
],
|
||||||
|
'configuration' => []
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.config-modal', ['plugin' => $plugin])
|
||||||
|
->set('multiValues.items.0', 'First')
|
||||||
|
->call('addMultiItem', 'items')
|
||||||
|
->set('multiValues.items.1', 'Second')
|
||||||
|
->call('saveConfiguration')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($plugin->fresh()->configuration['items'])->toBe('First,Second');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config modal resetForm clears dirty state and increments resetIndex', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'configuration' => ['simple_key' => 'original_value']
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.config-modal', ['plugin' => $plugin])
|
||||||
|
->set('configuration.simple_key', 'dirty_value')
|
||||||
|
->call('resetForm')
|
||||||
|
->assertSet('configuration.simple_key', 'original_value')
|
||||||
|
->assertSet('resetIndex', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config modal dispatches update event for parent warning refresh', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'uuid' => Str::uuid(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Test Plugin',
|
||||||
|
'data_strategy' => 'static'
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.config-modal', ['plugin' => $plugin])
|
||||||
|
->call('saveConfiguration')
|
||||||
|
->assertDispatched('config-updated');
|
||||||
|
});
|
||||||
112
tests/Feature/Livewire/Plugins/RecipeSettingsTest.php
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('recipe settings can save trmnlp_id', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'trmnlp_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$trmnlpId = (string) Str::uuid();
|
||||||
|
|
||||||
|
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
|
||||||
|
->set('trmnlp_id', $trmnlpId)
|
||||||
|
->call('saveTrmnlpId')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recipe settings validates trmnlp_id is unique per user', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$existingPlugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'trmnlp_id' => 'existing-id-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newPlugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'trmnlp_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.recipes.settings', ['plugin' => $newPlugin])
|
||||||
|
->set('trmnlp_id', 'existing-id-123')
|
||||||
|
->call('saveTrmnlpId')
|
||||||
|
->assertHasErrors(['trmnlp_id' => 'unique']);
|
||||||
|
|
||||||
|
expect($newPlugin->fresh()->trmnlp_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recipe settings allows same trmnlp_id for different users', function (): void {
|
||||||
|
$user1 = User::factory()->create();
|
||||||
|
$user2 = User::factory()->create();
|
||||||
|
|
||||||
|
$plugin1 = Plugin::factory()->create([
|
||||||
|
'user_id' => $user1->id,
|
||||||
|
'trmnlp_id' => 'shared-id-123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin2 = Plugin::factory()->create([
|
||||||
|
'user_id' => $user2->id,
|
||||||
|
'trmnlp_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user2);
|
||||||
|
|
||||||
|
Volt::test('plugins.recipes.settings', ['plugin' => $plugin2])
|
||||||
|
->set('trmnlp_id', 'shared-id-123')
|
||||||
|
->call('saveTrmnlpId')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($plugin2->fresh()->trmnlp_id)->toBe('shared-id-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recipe settings allows same trmnlp_id for the same plugin', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$trmnlpId = (string) Str::uuid();
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'trmnlp_id' => $trmnlpId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
|
||||||
|
->set('trmnlp_id', $trmnlpId)
|
||||||
|
->call('saveTrmnlpId')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($plugin->fresh()->trmnlp_id)->toBe($trmnlpId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recipe settings can clear trmnlp_id', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'trmnlp_id' => 'some-id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Volt::test('plugins.recipes.settings', ['plugin' => $plugin])
|
||||||
|
->set('trmnlp_id', '')
|
||||||
|
->call('saveTrmnlpId')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect($plugin->fresh()->trmnlp_id)->toBeNull();
|
||||||
|
});
|
||||||
|
|
@ -130,3 +130,48 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v
|
||||||
Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0));
|
Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0));
|
||||||
expect($playlist->isActiveNow())->toBeFalse();
|
expect($playlist->isActiveNow())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('playlist scheduling respects user timezone preference', function (): void {
|
||||||
|
// Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin)
|
||||||
|
// This simulates the bug where setting 00:15 doesn't work until one hour later
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer
|
||||||
|
]);
|
||||||
|
|
||||||
|
$device = Device::factory()->create(['user_id' => $user->id]);
|
||||||
|
|
||||||
|
// Create a playlist that should be active from 00:15 to 01:00 in the user's timezone
|
||||||
|
$playlist = Playlist::factory()->create([
|
||||||
|
'device_id' => $device->id,
|
||||||
|
'is_active' => true,
|
||||||
|
'active_from' => '00:15',
|
||||||
|
'active_until' => '01:00',
|
||||||
|
'weekdays' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set test time to 00:15 in the user's timezone (Europe/Berlin)
|
||||||
|
// In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day
|
||||||
|
// But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent
|
||||||
|
// For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC
|
||||||
|
$berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin');
|
||||||
|
Carbon::setTestNow($berlinTime->utc());
|
||||||
|
|
||||||
|
// The playlist should be active at 00:15 in the user's timezone
|
||||||
|
// This test should pass after the fix, but will fail with the current bug
|
||||||
|
expect($playlist->isActiveNow())->toBeTrue();
|
||||||
|
|
||||||
|
// Test at 00:30 in user's timezone - should still be active
|
||||||
|
$berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin');
|
||||||
|
Carbon::setTestNow($berlinTime->utc());
|
||||||
|
expect($playlist->isActiveNow())->toBeTrue();
|
||||||
|
|
||||||
|
// Test at 01:15 in user's timezone - should NOT be active (past the end time)
|
||||||
|
$berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin');
|
||||||
|
Carbon::setTestNow($berlinTime->utc());
|
||||||
|
expect($playlist->isActiveNow())->toBeFalse();
|
||||||
|
|
||||||
|
// Test at 00:10 in user's timezone - should NOT be active (before start time)
|
||||||
|
$berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin');
|
||||||
|
Carbon::setTestNow($berlinTime->utc());
|
||||||
|
expect($playlist->isActiveNow())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,19 +83,34 @@ it('throws exception for invalid zip file', function (): void {
|
||||||
->toThrow(Exception::class, 'Could not open the ZIP file.');
|
->toThrow(Exception::class, 'Could not open the ZIP file.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws exception for missing required files', function (): void {
|
it('throws exception for missing settings.yml', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$zipContent = createMockZipFile([
|
$zipContent = createMockZipFile([
|
||||||
'src/settings.yml' => getValidSettingsYaml(),
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
// Missing full.liquid
|
// Missing settings.yml
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
$pluginImportService = new PluginImportService();
|
$pluginImportService = new PluginImportService();
|
||||||
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
|
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
->toThrow(Exception::class, 'Invalid ZIP structure. Required files settings.yml and full.liquid are missing.');
|
->toThrow(Exception::class, 'Invalid ZIP structure. Required file settings.yml is missing.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws exception for missing template files', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
// Missing all template files
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
expect(fn (): Plugin => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
|
->toThrow(Exception::class, 'Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets default values when settings are missing', function (): void {
|
it('sets default values when settings are missing', function (): void {
|
||||||
|
|
@ -388,6 +403,142 @@ it('does not set icon_url when importing from URL without iconUrl parameter', fu
|
||||||
->and($plugin->icon_url)->toBeNull();
|
->and($plugin->icon_url)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes non-named select options to named values', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$settingsYaml = <<<'YAML'
|
||||||
|
name: Test Plugin
|
||||||
|
refresh_interval: 30
|
||||||
|
strategy: static
|
||||||
|
polling_verb: get
|
||||||
|
static_data: '{}'
|
||||||
|
custom_fields:
|
||||||
|
- keyname: display_incident
|
||||||
|
field_type: select
|
||||||
|
options:
|
||||||
|
- true
|
||||||
|
- false
|
||||||
|
default: true
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => $settingsYaml,
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
$customFields = $plugin->configuration_template['custom_fields'];
|
||||||
|
$displayIncidentField = collect($customFields)->firstWhere('keyname', 'display_incident');
|
||||||
|
|
||||||
|
expect($displayIncidentField)->not->toBeNull()
|
||||||
|
->and($displayIncidentField['options'])->toBe([
|
||||||
|
['true' => 'true'],
|
||||||
|
['false' => 'false'],
|
||||||
|
])
|
||||||
|
->and($displayIncidentField['default'])->toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws exception when multi_string default value contains a comma', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// YAML with a comma in the 'default' field of a multi_string
|
||||||
|
$invalidYaml = <<<'YAML'
|
||||||
|
name: Test Plugin
|
||||||
|
refresh_interval: 30
|
||||||
|
strategy: static
|
||||||
|
polling_verb: get
|
||||||
|
static_data: '{"test": "data"}'
|
||||||
|
custom_fields:
|
||||||
|
- keyname: api_key
|
||||||
|
field_type: multi_string
|
||||||
|
default: default-api-key1,default-api-key2
|
||||||
|
label: API Key
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => $invalidYaml,
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('invalid-default.zip', $zipContent);
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
|
||||||
|
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
|
->toThrow(Exception::class, 'Validation Error: The default value for multistring fields like `api_key` cannot contain commas.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws exception when multi_string placeholder contains a comma', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// YAML with a comma in the 'placeholder' field
|
||||||
|
$invalidYaml = <<<'YAML'
|
||||||
|
name: Test Plugin
|
||||||
|
refresh_interval: 30
|
||||||
|
strategy: static
|
||||||
|
polling_verb: get
|
||||||
|
static_data: '{"test": "data"}'
|
||||||
|
custom_fields:
|
||||||
|
- keyname: api_key
|
||||||
|
field_type: multi_string
|
||||||
|
default: default-api-key
|
||||||
|
label: API Key
|
||||||
|
placeholder: "value1, value2"
|
||||||
|
YAML;
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => $invalidYaml,
|
||||||
|
'src/full.liquid' => getValidFullLiquid(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('invalid-placeholder.zip', $zipContent);
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
|
||||||
|
expect(fn () => $pluginImportService->importFromZip($zipFile, $user))
|
||||||
|
->toThrow(Exception::class, 'Validation Error: The placeholder value for multistring fields like `api_key` cannot contain commas.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports plugin with only shared.liquid file', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
'src/shared.liquid' => '<div class="shared-content">{{ data.title }}</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
|
->and($plugin->markup_language)->toBe('liquid')
|
||||||
|
->and($plugin->render_markup)->toContain('<div class="view view--{{ size }}">')
|
||||||
|
->and($plugin->render_markup)->toContain('<div class="shared-content">{{ data.title }}</div>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports plugin with only shared.blade.php file', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$zipContent = createMockZipFile([
|
||||||
|
'src/settings.yml' => getValidSettingsYaml(),
|
||||||
|
'src/shared.blade.php' => '<div class="shared-content">{{ $data["title"] }}</div>',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$zipFile = UploadedFile::fake()->createWithContent('test-plugin.zip', $zipContent);
|
||||||
|
|
||||||
|
$pluginImportService = new PluginImportService();
|
||||||
|
$plugin = $pluginImportService->importFromZip($zipFile, $user);
|
||||||
|
|
||||||
|
expect($plugin)->toBeInstanceOf(Plugin::class)
|
||||||
|
->and($plugin->markup_language)->toBe('blade')
|
||||||
|
->and($plugin->render_markup)->toBe('<div class="shared-content">{{ $data["title"] }}</div>')
|
||||||
|
->and($plugin->render_markup)->not->toContain('<div class="view view--{{ size }}">');
|
||||||
|
});
|
||||||
|
|
||||||
// Helper methods
|
// Helper methods
|
||||||
function createMockZipFile(array $files): string
|
function createMockZipFile(array $files): string
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
test('plugin parses JSON responses correctly', function (): void {
|
test('plugin parses JSON responses correctly', function (): void {
|
||||||
|
|
@ -191,3 +192,96 @@ test('plugin handles POST requests with XML responses', function (): void {
|
||||||
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
expect($plugin->data_payload['rss']['status'])->toBe('success');
|
||||||
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
expect($plugin->data_payload['rss']['data'])->toBe('test');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin parses iCal responses and filters to recent window', function (): void {
|
||||||
|
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
|
||||||
|
|
||||||
|
$icalContent = <<<'ICS'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Example Corp.//CalDAV Client//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event-1@example.com
|
||||||
|
DTSTAMP:20250101T120000Z
|
||||||
|
DTSTART:20250110T090000Z
|
||||||
|
DTEND:20250110T100000Z
|
||||||
|
SUMMARY:Past within window
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event-2@example.com
|
||||||
|
DTSTAMP:20250101T120000Z
|
||||||
|
DTSTART:20250301T090000Z
|
||||||
|
DTEND:20250301T100000Z
|
||||||
|
SUMMARY:Far future
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event-3@example.com
|
||||||
|
DTSTAMP:20250101T120000Z
|
||||||
|
DTSTART:20250120T090000Z
|
||||||
|
DTEND:20250120T100000Z
|
||||||
|
SUMMARY:Upcoming within window
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
ICS;
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://example.com/calendar.ics',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
$plugin->refresh();
|
||||||
|
|
||||||
|
$ical = $plugin->data_payload['ical'];
|
||||||
|
|
||||||
|
expect($ical)->toHaveCount(2);
|
||||||
|
expect($ical[0]['SUMMARY'])->toBe('Past within window');
|
||||||
|
expect($ical[1]['SUMMARY'])->toBe('Upcoming within window');
|
||||||
|
expect(collect($ical)->pluck('SUMMARY'))->not->toContain('Far future');
|
||||||
|
expect($ical[0]['DTSTART'])->toBe('2025-01-10T09:00:00+00:00');
|
||||||
|
expect($ical[1]['DTSTART'])->toBe('2025-01-20T09:00:00+00:00');
|
||||||
|
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin detects iCal content without calendar content type', function (): void {
|
||||||
|
Carbon::setTestNow(Carbon::parse('2025-01-15 12:00:00', 'UTC'));
|
||||||
|
|
||||||
|
$icalContent = <<<'ICS'
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event-body-detected@example.com
|
||||||
|
DTSTAMP:20250101T120000Z
|
||||||
|
DTSTART:20250116T090000Z
|
||||||
|
DTEND:20250116T100000Z
|
||||||
|
SUMMARY:Detected by body
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
ICS;
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'example.com/calendar-body.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/plain']),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://example.com/calendar-body.ics',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
$plugin->refresh();
|
||||||
|
|
||||||
|
expect($plugin->data_payload)->toHaveKey('ical');
|
||||||
|
expect($plugin->data_payload['ical'])->toHaveCount(1);
|
||||||
|
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Detected by body');
|
||||||
|
expect($plugin->data_payload['ical'][0]['DTSTART'])->toBe('2025-01-16T09:00:00+00:00');
|
||||||
|
|
||||||
|
Carbon::setTestNow();
|
||||||
|
});
|
||||||
|
|
@ -7,7 +7,7 @@ use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Livewire\Volt\Volt;
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
it('loads newest TRMNL recipes on mount', function () {
|
it('loads newest TRMNL recipes on mount', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
|
|
@ -28,10 +28,34 @@ it('loads newest TRMNL recipes on mount', function () {
|
||||||
Volt::test('catalog.trmnl')
|
Volt::test('catalog.trmnl')
|
||||||
->assertSee('Weather Chum')
|
->assertSee('Weather Chum')
|
||||||
->assertSee('Install')
|
->assertSee('Install')
|
||||||
|
->assertDontSeeHtml('variant="subtle" icon="eye"')
|
||||||
->assertSee('Installs: 10');
|
->assertSee('Installs: 10');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('searches TRMNL recipes when search term is provided', function () {
|
it('shows preview button when screenshot_url is provided', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/screenshot.png',
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 10, 'forks' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Weather Chum')
|
||||||
|
->assertSee('Preview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('searches TRMNL recipes when search term is provided', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
// First call (mount -> newest)
|
// First call (mount -> newest)
|
||||||
'usetrmnl.com/recipes.json?*' => Http::sequence()
|
'usetrmnl.com/recipes.json?*' => Http::sequence()
|
||||||
|
|
@ -71,7 +95,7 @@ it('searches TRMNL recipes when search term is provided', function () {
|
||||||
->assertSee('Install');
|
->assertSee('Install');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('installs plugin successfully when user is authenticated', function () {
|
it('installs plugin successfully when user is authenticated', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
|
@ -100,7 +124,7 @@ it('installs plugin successfully when user is authenticated', function () {
|
||||||
->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
|
->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when user is not authenticated', function () {
|
it('shows error when user is not authenticated', function (): void {
|
||||||
Http::fake([
|
Http::fake([
|
||||||
'usetrmnl.com/recipes.json*' => Http::response([
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
'data' => [
|
'data' => [
|
||||||
|
|
@ -124,7 +148,7 @@ it('shows error when user is not authenticated', function () {
|
||||||
->assertStatus(403); // This will return 403 because user is not authenticated
|
->assertStatus(403); // This will return 403 because user is not authenticated
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when plugin installation fails', function () {
|
it('shows error when plugin installation fails', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
Http::fake([
|
Http::fake([
|
||||||
|
|
@ -152,3 +176,111 @@ it('shows error when plugin installation fails', function () {
|
||||||
->call('installPlugin', '123')
|
->call('installPlugin', '123')
|
||||||
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
->assertSee('Error installing plugin'); // This will fail because the zip content is invalid
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('previews a recipe with async fetch', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json*' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/old.png',
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 10, 'forks' => 2],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
'usetrmnl.com/recipes/123.json' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
'id' => 123,
|
||||||
|
'name' => 'Weather Chum Updated',
|
||||||
|
'icon_url' => 'https://example.com/icon.png',
|
||||||
|
'screenshot_url' => 'https://example.com/new.png',
|
||||||
|
'author_bio' => ['description' => 'New bio'],
|
||||||
|
'stats' => ['installs' => 11, 'forks' => 3],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Weather Chum')
|
||||||
|
->call('previewRecipe', '123')
|
||||||
|
->assertSet('previewingRecipe', '123')
|
||||||
|
->assertSet('previewData.name', 'Weather Chum Updated')
|
||||||
|
->assertSet('previewData.screenshot_url', 'https://example.com/new.png')
|
||||||
|
->assertSee('Preview Weather Chum Updated')
|
||||||
|
->assertSee('New bio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports pagination and loading more recipes', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'name' => 'Recipe Page 1',
|
||||||
|
'icon_url' => null,
|
||||||
|
'screenshot_url' => null,
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 1, 'forks' => 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
|
], 200),
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([
|
||||||
|
'data' => [
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'name' => 'Recipe Page 2',
|
||||||
|
'icon_url' => null,
|
||||||
|
'screenshot_url' => null,
|
||||||
|
'author_bio' => null,
|
||||||
|
'stats' => ['installs' => 2, 'forks' => 0],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'next_page_url' => null,
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Recipe Page 1')
|
||||||
|
->assertDontSee('Recipe Page 2')
|
||||||
|
->assertSee('Load next page')
|
||||||
|
->call('loadMore')
|
||||||
|
->assertSee('Recipe Page 1')
|
||||||
|
->assertSee('Recipe Page 2')
|
||||||
|
->assertDontSee('Load next page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets pagination when search term changes', function (): void {
|
||||||
|
Http::fake([
|
||||||
|
'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence()
|
||||||
|
->push([
|
||||||
|
'data' => [['id' => 1, 'name' => 'Initial 1']],
|
||||||
|
'next_page_url' => '/recipes.json?page=2',
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'data' => [['id' => 3, 'name' => 'Initial 1 Again']],
|
||||||
|
'next_page_url' => null,
|
||||||
|
]),
|
||||||
|
'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([
|
||||||
|
'data' => [['id' => 2, 'name' => 'Weather Result']],
|
||||||
|
'next_page_url' => null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::withoutLazyLoading();
|
||||||
|
|
||||||
|
Volt::test('catalog.trmnl')
|
||||||
|
->assertSee('Initial 1')
|
||||||
|
->call('loadMore')
|
||||||
|
->set('search', 'weather')
|
||||||
|
->assertSee('Weather Result')
|
||||||
|
->assertDontSee('Initial 1')
|
||||||
|
->assertSet('page', 1);
|
||||||
|
});
|
||||||
|
|
|
||||||
575
tests/Feature/Volt/DevicePalettesTest.php
Normal file
|
|
@ -0,0 +1,575 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\DevicePalette;
|
||||||
|
use App\Models\User;
|
||||||
|
use Livewire\Volt\Volt;
|
||||||
|
|
||||||
|
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
test('device palettes page can be rendered', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$this->get(route('device-palettes.index'))->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component loads all device palettes on mount', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$initialCount = DevicePalette::count();
|
||||||
|
DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
|
||||||
|
DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
|
||||||
|
DevicePalette::create(['name' => 'palette-3', 'grays' => 16, 'framework_class' => '']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index');
|
||||||
|
|
||||||
|
$palettes = $component->get('devicePalettes');
|
||||||
|
expect($palettes)->toHaveCount($initialCount + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can open modal to create new device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('editingDevicePaletteId', null)
|
||||||
|
->assertSet('viewingDevicePaletteId', null)
|
||||||
|
->assertSet('name', null)
|
||||||
|
->assertSet('grays', 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create a new device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('description', 'Test Palette Description')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('colors', ['#FF0000', '#00FF00'])
|
||||||
|
->set('framework_class', 'TestFramework')
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
expect(DevicePalette::where('name', 'test-palette')->exists())->toBeTrue();
|
||||||
|
|
||||||
|
$palette = DevicePalette::where('name', 'test-palette')->first();
|
||||||
|
expect($palette->description)->toBe('Test Palette Description');
|
||||||
|
expect($palette->grays)->toBe(16);
|
||||||
|
expect($palette->colors)->toBe(['#FF0000', '#00FF00']);
|
||||||
|
expect($palette->framework_class)->toBe('TestFramework');
|
||||||
|
expect($palette->source)->toBe('manual');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can create a grayscale-only palette without colors', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'grayscale-palette')
|
||||||
|
->set('grays', 256)
|
||||||
|
->set('colors', [])
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
$palette = DevicePalette::where('name', 'grayscale-palette')->first();
|
||||||
|
expect($palette->colors)->toBeNull();
|
||||||
|
expect($palette->grays)->toBe(256);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can open modal to edit existing device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'existing-palette',
|
||||||
|
'description' => 'Existing Description',
|
||||||
|
'grays' => 4,
|
||||||
|
'colors' => ['#FF0000', '#00FF00'],
|
||||||
|
'framework_class' => 'Framework',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('editingDevicePaletteId', $palette->id)
|
||||||
|
->assertSet('name', 'existing-palette')
|
||||||
|
->assertSet('description', 'Existing Description')
|
||||||
|
->assertSet('grays', 4)
|
||||||
|
->assertSet('colors', ['#FF0000', '#00FF00'])
|
||||||
|
->assertSet('framework_class', 'Framework');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can update an existing device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'original-palette',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id)
|
||||||
|
->set('name', 'updated-palette')
|
||||||
|
->set('description', 'Updated Description')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('colors', ['#0000FF'])
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
$palette->refresh();
|
||||||
|
expect($palette->name)->toBe('updated-palette');
|
||||||
|
expect($palette->description)->toBe('Updated Description');
|
||||||
|
expect($palette->grays)->toBe(16);
|
||||||
|
expect($palette->colors)->toBe(['#0000FF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can delete a device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'to-delete',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('deleteDevicePalette', $palette->id);
|
||||||
|
|
||||||
|
expect(DevicePalette::find($palette->id))->toBeNull();
|
||||||
|
$component->assertSet('devicePalettes', function ($palettes) use ($palette) {
|
||||||
|
return $palettes->where('id', $palette->id)->isEmpty();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can duplicate a device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'original-palette',
|
||||||
|
'description' => 'Original Description',
|
||||||
|
'grays' => 4,
|
||||||
|
'colors' => ['#FF0000', '#00FF00'],
|
||||||
|
'framework_class' => 'Framework',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('duplicateDevicePalette', $palette->id);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('editingDevicePaletteId', null)
|
||||||
|
->assertSet('name', 'original-palette (Copy)')
|
||||||
|
->assertSet('description', 'Original Description')
|
||||||
|
->assertSet('grays', 4)
|
||||||
|
->assertSet('colors', ['#FF0000', '#00FF00'])
|
||||||
|
->assertSet('framework_class', 'Framework');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add a color to the colors array', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colorInput', '#FF0000')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('colors', ['#FF0000'])
|
||||||
|
->assertSet('colorInput', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cannot add duplicate colors', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colors', ['#FF0000'])
|
||||||
|
->set('colorInput', '#FF0000')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertSet('colors', ['#FF0000']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add multiple colors', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colorInput', '#FF0000')
|
||||||
|
->call('addColor')
|
||||||
|
->set('colorInput', '#00FF00')
|
||||||
|
->call('addColor')
|
||||||
|
->set('colorInput', '#0000FF')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('colors', ['#FF0000', '#00FF00', '#0000FF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can remove a color from the colors array', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
|
||||||
|
->call('removeColor', 1);
|
||||||
|
|
||||||
|
$component->assertSet('colors', ['#FF0000', '#0000FF']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removing color reindexes array', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colors', ['#FF0000', '#00FF00', '#0000FF'])
|
||||||
|
->call('removeColor', 0);
|
||||||
|
|
||||||
|
$colors = $component->get('colors');
|
||||||
|
expect($colors)->toBe(['#00FF00', '#0000FF']);
|
||||||
|
expect(array_keys($colors))->toBe([0, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can open modal in view-only mode for api-sourced palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'api-palette',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'api',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id, true);
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('viewingDevicePaletteId', $palette->id)
|
||||||
|
->assertSet('editingDevicePaletteId', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name is required when creating device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('grays', 16)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name must be unique when creating device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
DevicePalette::create([
|
||||||
|
'name' => 'existing-name',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'existing-name')
|
||||||
|
->set('grays', 16)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['name']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('name can be same when updating device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'original-name',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id)
|
||||||
|
->set('grays', 16)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grays is required when creating device palette', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', null)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['grays']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grays must be at least 1', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 0)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['grays']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grays must be at most 256', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 257)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['grays']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('colors must be valid hex format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('colors', ['invalid-color', '#FF0000'])
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['colors.0']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('color input must be valid hex format when adding color', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colorInput', 'invalid-color')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component->assertHasErrors(['colorInput']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('color input accepts valid hex format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colorInput', '#FF0000')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('color input accepts lowercase hex format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('colorInput', '#ff0000')
|
||||||
|
->call('addColor');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('description can be null', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('description', null)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
$palette = DevicePalette::where('name', 'test-palette')->first();
|
||||||
|
expect($palette->description)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('framework class can be empty string', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('framework_class', '')
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
$palette = DevicePalette::where('name', 'test-palette')->first();
|
||||||
|
expect($palette->framework_class)->toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty colors array is saved as null', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('colors', [])
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component->assertHasNoErrors();
|
||||||
|
|
||||||
|
$palette = DevicePalette::where('name', 'test-palette')->first();
|
||||||
|
expect($palette->colors)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component resets form after saving', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'test-palette')
|
||||||
|
->set('description', 'Test Description')
|
||||||
|
->set('grays', 16)
|
||||||
|
->set('colors', ['#FF0000'])
|
||||||
|
->set('framework_class', 'TestFramework')
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertSet('name', null)
|
||||||
|
->assertSet('description', null)
|
||||||
|
->assertSet('grays', 2)
|
||||||
|
->assertSet('colors', [])
|
||||||
|
->assertSet('framework_class', '')
|
||||||
|
->assertSet('colorInput', '')
|
||||||
|
->assertSet('editingDevicePaletteId', null)
|
||||||
|
->assertSet('viewingDevicePaletteId', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component handles palette with null colors when editing', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'grayscale-palette',
|
||||||
|
'grays' => 2,
|
||||||
|
'colors' => null,
|
||||||
|
'framework_class' => '',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id);
|
||||||
|
|
||||||
|
$component->assertSet('colors', []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component handles palette with string colors when editing', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$palette = DevicePalette::create([
|
||||||
|
'name' => 'string-colors-palette',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
]);
|
||||||
|
// Manually set colors as JSON string to simulate edge case
|
||||||
|
$palette->setRawAttributes(array_merge($palette->getAttributes(), [
|
||||||
|
'colors' => json_encode(['#FF0000', '#00FF00']),
|
||||||
|
]));
|
||||||
|
$palette->save();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('openDevicePaletteModal', $palette->id);
|
||||||
|
|
||||||
|
$component->assertSet('colors', ['#FF0000', '#00FF00']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component refreshes palette list after creating', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$initialCount = DevicePalette::count();
|
||||||
|
DevicePalette::create(['name' => 'palette-1', 'grays' => 2, 'framework_class' => '']);
|
||||||
|
DevicePalette::create(['name' => 'palette-2', 'grays' => 4, 'framework_class' => '']);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->set('name', 'new-palette')
|
||||||
|
->set('grays', 16)
|
||||||
|
->call('saveDevicePalette');
|
||||||
|
|
||||||
|
$palettes = $component->get('devicePalettes');
|
||||||
|
expect($palettes)->toHaveCount($initialCount + 3);
|
||||||
|
expect(DevicePalette::count())->toBe($initialCount + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('component refreshes palette list after deleting', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$initialCount = DevicePalette::count();
|
||||||
|
$palette1 = DevicePalette::create([
|
||||||
|
'name' => 'palette-1',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
$palette2 = DevicePalette::create([
|
||||||
|
'name' => 'palette-2',
|
||||||
|
'grays' => 2,
|
||||||
|
'framework_class' => '',
|
||||||
|
'source' => 'manual',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Volt::test('device-palettes.index')
|
||||||
|
->call('deleteDevicePalette', $palette1->id);
|
||||||
|
|
||||||
|
$palettes = $component->get('devicePalettes');
|
||||||
|
expect($palettes)->toHaveCount($initialCount + 1);
|
||||||
|
expect(DevicePalette::count())->toBe($initialCount + 1);
|
||||||
|
});
|
||||||
|
|
@ -77,7 +77,7 @@ test('l_date handles null locale parameter', function (): void {
|
||||||
$filter = new Localization();
|
$filter = new Localization();
|
||||||
$date = '2025-01-11';
|
$date = '2025-01-11';
|
||||||
|
|
||||||
$result = $filter->l_date($date, 'Y-m-d', null);
|
$result = $filter->l_date($date, 'Y-m-d');
|
||||||
|
|
||||||
// Should work the same as default
|
// Should work the same as default
|
||||||
expect($result)->toContain('2025');
|
expect($result)->toContain('2025');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Plugin;
|
use App\Models\Plugin;
|
||||||
|
use App\Models\User;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
@ -97,6 +99,35 @@ test('updateDataPayload handles multiple URLs with IDX_ prefixes', function ():
|
||||||
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
|
expect($plugin->data_payload['IDX_2'])->toBe(['headline' => 'test']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('updateDataPayload skips empty lines in polling_url and maintains sequential IDX keys', function (): void {
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
// empty lines and extra spaces between the URL to generate empty entries
|
||||||
|
'polling_url' => "https://api1.example.com/data\n \n\nhttps://api2.example.com/weather\n ",
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock only the valid URLs
|
||||||
|
Http::fake([
|
||||||
|
'https://api1.example.com/data' => Http::response(['item' => 'first'], 200),
|
||||||
|
'https://api2.example.com/weather' => Http::response(['item' => 'second'], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin->updateDataPayload();
|
||||||
|
|
||||||
|
// payload should only have 2 items, and they should be indexed 0 and 1
|
||||||
|
expect($plugin->data_payload)->toHaveCount(2);
|
||||||
|
expect($plugin->data_payload)->toHaveKey('IDX_0');
|
||||||
|
expect($plugin->data_payload)->toHaveKey('IDX_1');
|
||||||
|
|
||||||
|
// data is correct
|
||||||
|
expect($plugin->data_payload['IDX_0'])->toBe(['item' => 'first']);
|
||||||
|
expect($plugin->data_payload['IDX_1'])->toBe(['item' => 'second']);
|
||||||
|
|
||||||
|
// no empty index exists
|
||||||
|
expect($plugin->data_payload)->not->toHaveKey('IDX_2');
|
||||||
|
});
|
||||||
|
|
||||||
test('updateDataPayload handles single URL without nesting', function (): void {
|
test('updateDataPayload handles single URL without nesting', function (): void {
|
||||||
$plugin = Plugin::factory()->create([
|
$plugin = Plugin::factory()->create([
|
||||||
'data_strategy' => 'polling',
|
'data_strategy' => 'polling',
|
||||||
|
|
@ -587,3 +618,323 @@ LIQUID
|
||||||
return str_contains($command, 'trmnl-liquid-cli');
|
return str_contains($command, 'trmnl-liquid-cli');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plugin render uses user timezone when set', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => 'America/New_York',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = $plugin->render();
|
||||||
|
|
||||||
|
expect($rendered)->toContain('America/New_York');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin render falls back to app timezone when user timezone is not set', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
config(['app.timezone' => 'Europe/London']);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => '{{ trmnl.user.time_zone_iana }}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = $plugin->render();
|
||||||
|
|
||||||
|
expect($rendered)->toContain('Europe/London');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin render calculates correct UTC offset from user timezone', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => 'America/New_York', // UTC-5 (EST) or UTC-4 (EDT)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => '{{ trmnl.user.utc_offset }}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = $plugin->render();
|
||||||
|
|
||||||
|
// America/New_York offset should be -18000 (EST) or -14400 (EDT) in seconds
|
||||||
|
$expectedOffset = (string) Carbon::now('America/New_York')->getOffset();
|
||||||
|
expect($rendered)->toContain($expectedOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin render calculates correct UTC offset from app timezone when user timezone is null', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
config(['app.timezone' => 'Europe/London']);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => '{{ trmnl.user.utc_offset }}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = $plugin->render();
|
||||||
|
|
||||||
|
// Europe/London offset should be 0 (GMT) or 3600 (BST) in seconds
|
||||||
|
$expectedOffset = (string) Carbon::now('Europe/London')->getOffset();
|
||||||
|
expect($rendered)->toContain($expectedOffset);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin render includes utc_offset and time_zone_iana in trmnl.user context', function (): void {
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'timezone' => 'America/Chicago', // UTC-6 (CST) or UTC-5 (CDT)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plugin = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'markup_language' => 'liquid',
|
||||||
|
'render_markup' => '{{ trmnl.user.time_zone_iana }}|{{ trmnl.user.utc_offset }}',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$rendered = $plugin->render();
|
||||||
|
|
||||||
|
expect($rendered)
|
||||||
|
->toContain('America/Chicago')
|
||||||
|
->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds)
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin security: XSS Payload Dataset
|
||||||
|
* [Input, Expected Result, Forbidden String]
|
||||||
|
*/
|
||||||
|
dataset('xss_vectors', [
|
||||||
|
'standard_script' => ['Safe <script>alert(1)</script>', 'Safe ', '<script>'],
|
||||||
|
'attribute_event_handlers' => ['<a onmouseover="alert(1)">Link</a>', '<a>Link</a>', 'onmouseover'],
|
||||||
|
'javascript_protocol' => ['<a href="javascript:alert(1)">Click</a>', '<a>Click</a>', 'javascript:'],
|
||||||
|
'iframe_injection' => ['Watch <iframe src="https://x.com"></iframe>', 'Watch ', '<iframe>'],
|
||||||
|
'img_onerror_fallback' => ['Photo <img src=x onerror=alert(1)>', 'Photo <img src="x" alt="x">', 'onerror'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
test('plugin model sanitizes template fields on save', function (string $input, string $expected, string $forbidden): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// We test the Model logic directly. This triggers the static::saving hook.
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Security Test',
|
||||||
|
'data_stale_minutes' => 15,
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'configuration_template' => [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'test_field',
|
||||||
|
'description' => $input,
|
||||||
|
'help_text' => $input,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$field = $plugin->fresh()->configuration_template['custom_fields'][0];
|
||||||
|
|
||||||
|
// Assert the saved data is clean
|
||||||
|
expect($field['description'])->toBe($expected)
|
||||||
|
->and($field['help_text'])->toBe($expected)
|
||||||
|
->and($field['description'])->not->toContain($forbidden);
|
||||||
|
})->with('xss_vectors');
|
||||||
|
|
||||||
|
test('plugin model preserves multi_string csv format', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$plugin = Plugin::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Multi-string Test',
|
||||||
|
'data_stale_minutes' => 15,
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'configuration' => [
|
||||||
|
'tags' => 'laravel,pest,security',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($plugin->fresh()->configuration['tags'])->toBe('laravel,pest,security');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate copies all attributes except id and uuid', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Original Plugin',
|
||||||
|
'data_stale_minutes' => 30,
|
||||||
|
'data_strategy' => 'polling',
|
||||||
|
'polling_url' => 'https://api.example.com/data',
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'polling_header' => 'Authorization: Bearer token123',
|
||||||
|
'polling_body' => '{"query": "test"}',
|
||||||
|
'render_markup' => '<div>Test markup</div>',
|
||||||
|
'markup_language' => 'blade',
|
||||||
|
'configuration' => ['api_key' => 'secret123'],
|
||||||
|
'configuration_template' => [
|
||||||
|
'custom_fields' => [
|
||||||
|
[
|
||||||
|
'keyname' => 'api_key',
|
||||||
|
'field_type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'no_bleed' => true,
|
||||||
|
'dark_mode' => true,
|
||||||
|
'data_payload' => ['test' => 'data'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate();
|
||||||
|
|
||||||
|
// Refresh to ensure casts are applied
|
||||||
|
$original->refresh();
|
||||||
|
$duplicate->refresh();
|
||||||
|
|
||||||
|
expect($duplicate->id)->not->toBe($original->id)
|
||||||
|
->and($duplicate->uuid)->not->toBe($original->uuid)
|
||||||
|
->and($duplicate->name)->toBe('Original Plugin (Copy)')
|
||||||
|
->and($duplicate->user_id)->toBe($original->user_id)
|
||||||
|
->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes)
|
||||||
|
->and($duplicate->data_strategy)->toBe($original->data_strategy)
|
||||||
|
->and($duplicate->polling_url)->toBe($original->polling_url)
|
||||||
|
->and($duplicate->polling_verb)->toBe($original->polling_verb)
|
||||||
|
->and($duplicate->polling_header)->toBe($original->polling_header)
|
||||||
|
->and($duplicate->polling_body)->toBe($original->polling_body)
|
||||||
|
->and($duplicate->render_markup)->toBe($original->render_markup)
|
||||||
|
->and($duplicate->markup_language)->toBe($original->markup_language)
|
||||||
|
->and($duplicate->configuration)->toBe($original->configuration)
|
||||||
|
->and($duplicate->configuration_template)->toBe($original->configuration_template)
|
||||||
|
->and($duplicate->no_bleed)->toBe($original->no_bleed)
|
||||||
|
->and($duplicate->dark_mode)->toBe($original->dark_mode)
|
||||||
|
->and($duplicate->data_payload)->toBe($original->data_payload)
|
||||||
|
->and($duplicate->render_markup_view)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate copies render_markup_view file content to render_markup', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Create a test blade file
|
||||||
|
$testViewPath = resource_path('views/recipes/test-duplicate.blade.php');
|
||||||
|
$testContent = '<div class="test-view">Test Content</div>';
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir(dirname($testViewPath))) {
|
||||||
|
mkdir(dirname($testViewPath), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($testViewPath, $testContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'View Plugin',
|
||||||
|
'render_markup' => null,
|
||||||
|
'render_markup_view' => 'recipes.test-duplicate',
|
||||||
|
'markup_language' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate();
|
||||||
|
|
||||||
|
expect($duplicate->render_markup)->toBe($testContent)
|
||||||
|
->and($duplicate->markup_language)->toBe('blade')
|
||||||
|
->and($duplicate->render_markup_view)->toBeNull()
|
||||||
|
->and($duplicate->name)->toBe('View Plugin (Copy)');
|
||||||
|
} finally {
|
||||||
|
// Clean up test file
|
||||||
|
if (file_exists($testViewPath)) {
|
||||||
|
unlink($testViewPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate handles liquid file extension', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
// Create a test liquid file
|
||||||
|
$testViewPath = resource_path('views/recipes/test-duplicate-liquid.liquid');
|
||||||
|
$testContent = '<div class="test-view">{{ data.message }}</div>';
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir(dirname($testViewPath))) {
|
||||||
|
mkdir(dirname($testViewPath), 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents($testViewPath, $testContent);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Liquid Plugin',
|
||||||
|
'render_markup' => null,
|
||||||
|
'render_markup_view' => 'recipes.test-duplicate-liquid',
|
||||||
|
'markup_language' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate();
|
||||||
|
|
||||||
|
expect($duplicate->render_markup)->toBe($testContent)
|
||||||
|
->and($duplicate->markup_language)->toBe('liquid')
|
||||||
|
->and($duplicate->render_markup_view)->toBeNull();
|
||||||
|
} finally {
|
||||||
|
// Clean up test file
|
||||||
|
if (file_exists($testViewPath)) {
|
||||||
|
unlink($testViewPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate handles missing view file gracefully', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Missing View Plugin',
|
||||||
|
'render_markup' => null,
|
||||||
|
'render_markup_view' => 'recipes.nonexistent-view',
|
||||||
|
'markup_language' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate();
|
||||||
|
|
||||||
|
expect($duplicate->render_markup_view)->toBeNull()
|
||||||
|
->and($duplicate->name)->toBe('Missing View Plugin (Copy)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate uses provided user_id', function (): void {
|
||||||
|
$user1 = User::factory()->create();
|
||||||
|
$user2 = User::factory()->create();
|
||||||
|
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user1->id,
|
||||||
|
'name' => 'Original Plugin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate($user2->id);
|
||||||
|
|
||||||
|
expect($duplicate->user_id)->toBe($user2->id)
|
||||||
|
->and($duplicate->user_id)->not->toBe($original->user_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plugin duplicate falls back to original user_id when no user_id provided', function (): void {
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$original = Plugin::factory()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => 'Original Plugin',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$duplicate = $original->duplicate();
|
||||||
|
|
||||||
|
expect($duplicate->user_id)->toBe($original->user_id);
|
||||||
|
});
|
||||||
|
|
|
||||||