Compare commits

..

16 commits

Author SHA1 Message Date
Benjamin Nussbaum
7b642c7eeb chore: OSS catalog, archive import are now beta 2025-11-06 21:53:55 +01:00
Benjamin Nussbaum
3ff0a6d8aa fix: center recipe items vertically 2025-11-06 21:53:55 +01:00
Benjamin Nussbaum
cf7285b64f feat: reposition filter button 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
e1ae0b4d3b fix: increase cache 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
34157cf4e5 feat: rearrange Add Recipe context menu 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
d1776e59e3 feat: add plugin funnel button to reveal search and sort options 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
471340ac16 feat: set icon url on import 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
e427932dd0 feat: show plugin icon from url 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
9c37352c17 fix: check arg length (external liquid renderer) 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
02e695fe4d fix: require trmnl-liquid to install recipes from TRMNL catalog 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
b98cda881e feat: set preferred_renderer when installing from catalog 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
3ae8a610a0 feat: add trmnl-liquid renderer option 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
03c74f9575 feat: add installation function 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
0da18e0e5a strip tags 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
de0ecab67e feat: add TRMNL recipe catalog 2025-11-06 21:53:54 +01:00
Benjamin Nussbaum
ee3df85c2f feat: add TRMNL recipe catalog 2025-11-06 21:53:54 +01:00
168 changed files with 3370 additions and 11716 deletions

View file

@ -9,11 +9,7 @@ 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 \
freetype-dev \
libpng-dev \
libjpeg-turbo-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1 ENV PUPPETEER_DOCKER=1
@ -22,10 +18,8 @@ RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install imagick zip gd RUN docker-php-ext-install imagick
# 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

View file

@ -14,12 +14,7 @@ RUN apk add --no-cache \
nodejs \ nodejs \
npm \ npm \
imagemagick-dev \ imagemagick-dev \
chromium \ chromium
libzip-dev \
freetype-dev \
libpng-dev \
libjpeg-turbo-dev
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_DOCKER=1 ENV PUPPETEER_DOCKER=1
@ -28,10 +23,8 @@ RUN mkdir -p /usr/src/php/ext/imagick
RUN chmod 777 /usr/src/php/ext/imagick RUN chmod 777 /usr/src/php/ext/imagick
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Install PHP extensions # Install PHP extensions
RUN docker-php-ext-install imagick zip gd RUN docker-php-ext-install imagick
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

8
.gitignore vendored
View file

@ -29,11 +29,3 @@ 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
/.claude
/AGENTS.md
/opencode.json

View file

@ -1,7 +1,7 @@
######################## ########################
# Base Image # Base Image
######################## ########################
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium 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"
@ -18,8 +18,6 @@ ENV TRMNL_LIQUID_ENABLED=1
# Switch to the root user so we can do root things # Switch to the root user so we can do root things
USER root USER root
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Set the working directory # Set the working directory
WORKDIR /var/www/html WORKDIR /var/www/html
@ -53,5 +51,7 @@ FROM base AS production
# Copy the assets from the assets image # Copy the assets from the assets image
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:latest /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
# Drop back to the www-data user # Drop back to the www-data user
USER www-data USER www-data

View file

@ -3,7 +3,7 @@
[![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](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** (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, its the most popular community-driven BYOS. 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, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot.png)
![Screenshot](README_byos-screenshot-dark.png) ![Screenshot](README_byos-screenshot-dark.png)
@ -16,8 +16,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* 🔍 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)
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/) * Over 45 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
@ -25,7 +24,6 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* 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))

View file

@ -1,33 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
/**
* Validate and create a newly registered user.
*
* @param array<string, string> $input
*/
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'password' => $this->passwordRules(),
])->validate();
return User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => $input['password'],
]);
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => $input['password'],
])->save();
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Concerns;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Concerns;
use App\Models\User;
use Illuminate\Validation\Rule;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
*/
protected function profileRules(?int $userId = null): array
{
return [
'name' => $this->nameRules(),
'email' => $this->emailRules($userId),
];
}
/**
* Get the validation rules used to validate user names.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function nameRules(): array
{
return ['required', 'string', 'max:255'];
}
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function emailRules(?int $userId = null): array
{
return [
'required',
'string',
'email',
'max:255',
$userId === null
? Rule::unique(User::class)
: Rule::unique(User::class)->ignore($userId),
];
}
}

View file

@ -121,10 +121,6 @@ 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);

View file

@ -1,24 +0,0 @@
<?php
namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
* @method static \App\Services\QrCodeService format(string $format)
* @method static \App\Services\QrCodeService size(int $size)
* @method static \App\Services\QrCodeService errorCorrection(string $level)
* @method static string generate(string $text)
*
* @see \App\Services\QrCodeService
*/
class QrCode extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
return 'qr-code';
}
}

View file

@ -5,7 +5,6 @@ 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;
@ -21,8 +20,6 @@ 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.
*/ */
@ -37,8 +34,6 @@ 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()) {
@ -74,86 +69,6 @@ final class FetchDeviceModelsJob implements ShouldQueue
} }
} }
/**
* Process palettes from API and update/create records.
*/
private function processPalettes(): void
{
try {
$response = Http::timeout(30)->get(self::PALETTES_API_URL);
if (! $response->successful()) {
Log::error('Failed to fetch palettes from API', [
'status' => $response->status(),
'body' => $response->body(),
]);
return;
}
$data = $response->json('data', []);
if (! is_array($data)) {
Log::error('Invalid response format from palettes API', [
'response' => $response->json(),
]);
return;
}
foreach ($data as $paletteData) {
try {
$this->updateOrCreatePalette($paletteData);
} catch (Exception $e) {
Log::error('Failed to process palette', [
'palette_data' => $paletteData,
'error' => $e->getMessage(),
]);
}
}
Log::info('Successfully fetched and updated palettes', [
'count' => count($data),
]);
} catch (Exception $e) {
Log::error('Exception occurred while fetching palettes', [
'message' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
}
}
/**
* Update or create a palette record.
*/
private function updateOrCreatePalette(array $paletteData): void
{
$name = $paletteData['id'] ?? null;
if (! $name) {
Log::warning('Palette data missing id field', [
'palette_data' => $paletteData,
]);
return;
}
$attributes = [
'name' => $name,
'description' => $paletteData['name'] ?? '',
'grays' => $paletteData['grays'] ?? 2,
'colors' => $paletteData['colors'] ?? null,
'framework_class' => $paletteData['framework_class'] ?? '',
'source' => 'api',
];
DevicePalette::updateOrCreate(
['name' => $name],
$attributes
);
}
/** /**
* Process the device models data and update/create records. * Process the device models data and update/create records.
*/ */
@ -199,49 +114,12 @@ final class FetchDeviceModelsJob implements ShouldQueue
'offset_x' => $modelData['offset_x'] ?? 0, 'offset_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;
}
} }

View file

@ -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);
} }
} }

View file

@ -2,7 +2,6 @@
namespace App\Liquid\Filters; namespace App\Liquid\Filters;
use App\Facades\QrCode;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Keepsuit\Liquid\Filters\FiltersProvider; use Keepsuit\Liquid\Filters\FiltersProvider;
@ -59,31 +58,4 @@ class StringMarkup extends FiltersProvider
{ {
return strip_tags($html); return strip_tags($html);
} }
/**
* Generate a QR code as SVG from the input text
*
* @param string $text The text to encode in the QR code
* @param int|null $moduleSize Optional module size (defaults to 11, which equals 319px)
* @param string|null $errorCorrection Optional error correction level: 'l', 'm', 'q', 'h' (defaults to 'm')
* @return string The SVG QR code
*/
public function qr_code(string $text, ?int $moduleSize = null, ?string $errorCorrection = null): string
{
// Default module_size is 11
// Size calculation: (21 modules for QR code + 4 modules margin on each side * 2) * module_size
// = (21 + 8) * module_size = 29 * module_size
$moduleSize ??= 11;
$size = 29 * $moduleSize;
$qrCode = QrCode::format('svg')
->size($size);
// Set error correction level if provided
if ($errorCorrection !== null) {
$qrCode->errorCorrection($errorCorrection);
}
return $qrCode->generate($text);
}
} }

View file

@ -12,7 +12,7 @@ class DeviceAutoJoin extends Component
public function mount(): void public function mount(): void
{ {
$this->deviceAutojoin = (bool) (auth()->user()->assign_new_devices ?? false); $this->deviceAutojoin = auth()->user()->assign_new_devices;
$this->isFirstUser = auth()->user()->id === 1; $this->isFirstUser = auth()->user()->id === 1;
} }

View file

@ -12,7 +12,6 @@ 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
{ {
@ -20,14 +19,6 @@ 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',
@ -196,11 +187,6 @@ 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.
*/ */

View file

@ -6,11 +6,7 @@ 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;
@ -39,7 +35,7 @@ final class DeviceModel extends Model
return '2bit'; return '2bit';
} }
// if higher than 4 return 4bit // if higher then 4 return 4bit
if ($this->bit_depth > 4) { if ($this->bit_depth > 4) {
return '4bit'; return '4bit';
} }
@ -70,9 +66,4 @@ final class DeviceModel extends Model
return null; return null;
} }
public function palette(): BelongsTo
{
return $this->belongsTo(DevicePalette::class, 'palette_id');
}
} }

View file

@ -1,23 +0,0 @@
<?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',
];
}

View file

@ -37,32 +37,21 @@ class Playlist extends Model
return false; return false;
} }
// Get user's timezone or fall back to app timezone // Check weekday
$timezone = $this->device->user->timezone ?? config('app.timezone'); if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) {
$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) {
// Create timezone-aware datetime objects for active_from and active_until $now = now();
$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 ($activeFrom > $activeUntil) { if ($this->active_from > $this->active_until) {
// Time range spans midnight (e.g., 09:01 to 03:58) // Time range spans midnight (e.g., 09:01 to 03:58)
if ($now >= $activeFrom || $now <= $activeUntil) { if ($now >= $this->active_from || $now <= $this->active_until) {
return true; return true;
} }
} elseif ($now >= $activeFrom && $now <= $activeUntil) { } elseif ($now >= $this->active_from && $now <= $this->active_until) {
return true; return true;
} }

View file

@ -11,9 +11,7 @@ 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;
@ -24,10 +22,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
{ {
@ -45,8 +43,6 @@ 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()
@ -58,18 +54,6 @@ 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()
@ -77,25 +61,6 @@ 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'])) {
@ -136,11 +101,6 @@ 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());
@ -154,13 +114,12 @@ 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']; $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
if ($this->polling_header) { if ($this->polling_header) {
// Resolve Liquid variables in the polling header
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header); $resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
$headerLines = explode("\n", mb_trim($resolvedHeader)); $headerLines = explode("\n", mb_trim($resolvedHeader));
foreach ($headerLines as $line) { foreach ($headerLines as $line) {
@ -171,71 +130,144 @@ class Plugin extends Model
} }
} }
// resolve and clean URLs // Split URLs by newline and filter out empty lines
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url); $urls = array_filter(
$urls = array_values(array_filter( // array_values ensures 0, 1, 2... array_map('trim', explode("\n", $this->polling_url)),
array_map(trim(...), explode("\n", $resolvedPollingUrls)), fn ($url): bool => ! empty($url)
filled(...) );
));
$combinedResponse = []; // If only one URL, use the original logic without nesting
if (count($urls) === 1) {
// Loop through all URLs (Handles 1 or many) $url = reset($urls);
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers); $httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) { if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body); $resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody); $httpRequest = $httpRequest->withBody($resolvedBody);
} }
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try { try {
$httpResponse = ($this->polling_verb === 'post') // Make the request based on the verb
? $httpRequest->post($url) $httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
: $httpRequest->get($url);
$response = $this->parseResponse($httpResponse); $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([ $this->update([
'data_payload' => $finalPayload, '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(), 'data_payload_updated_at' => now(),
]); ]);
} }
private function parseResponse(Response $httpResponse): array return;
{ }
$parsers = app(ResponseParserRegistry::class)->getParsers();
foreach ($parsers as $parser) { // Multiple URLs - use nested response logic
$parserName = class_basename($parser); $combinedResponse = [];
foreach ($urls as $index => $url) {
$httpRequest = Http::withHeaders($headers);
if ($this->polling_verb === 'post' && $this->polling_body) {
// Resolve Liquid variables in the polling body
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
$httpRequest = $httpRequest->withBody($resolvedBody);
}
// Resolve Liquid variables in the polling URL
$resolvedUrl = $this->resolveLiquidVariables($url);
try { try {
$result = $parser->parse($httpResponse); // Make the request based on the verb
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
if ($result !== null) { $response = $this->parseResponse($httpResponse);
return $result;
// 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) { } catch (Exception $e) {
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage()); // 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'];
} }
} }
return ['error' => 'Failed to parse response']; $this->update([
'data_payload' => $combinedResponse,
'data_payload_updated_at' => now(),
]);
}
}
/**
* Parse HTTP response, handling both JSON and XML content types
*/
private function parseResponse(Response $httpResponse): array
{
if ($httpResponse->header('Content-Type') && str_contains($httpResponse->header('Content-Type'), 'xml')) {
try {
// Convert XML to array and wrap under 'rss' key
$xml = simplexml_load_string($httpResponse->body());
if ($xml === false) {
throw new Exception('Invalid XML content');
}
// Convert SimpleXML directly to array
$xmlArray = $this->xmlToArray($xml);
return ['rss' => $xmlArray];
} catch (Exception $e) {
Log::warning('Failed to parse XML response: '.$e->getMessage());
return ['error' => 'Failed to parse XML response'];
}
}
try {
// 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;
} }
/** /**
@ -312,48 +344,19 @@ class Plugin extends Model
return $template; return $template;
} }
/**
* Check if a template contains a Liquid for loop pattern
*
* @param string $template The template string to check
* @return bool True if the template contains a for loop pattern
*/
private function containsLiquidForLoop(string $template): bool
{
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
}
/** /**
* Resolve Liquid variables in a template string using the Liquid template engine * Resolve Liquid variables in a template string using the Liquid template engine
* *
* Uses the external trmnl-liquid renderer when:
* - preferred_renderer is 'trmnl-liquid'
* - External renderer is enabled in config
* - Template contains a Liquid for loop pattern
*
* Otherwise uses the internal PHP-based Liquid renderer.
*
* @param string $template The template string containing Liquid variables * @param string $template The template string containing Liquid variables
* @return string The resolved template with variables replaced with their values * @return string The resolved template with variables replaced with their values
* *
* @throws LiquidException * @throws LiquidException
* @throws Exception
*/ */
public function resolveLiquidVariables(string $template): string public function resolveLiquidVariables(string $template): string
{ {
// Get configuration variables - make them available at root level // Get configuration variables - make them available at root level
$variables = $this->configuration ?? []; $variables = $this->configuration ?? [];
// Check if external renderer should be used
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
&& config('services.trmnl.liquid_enabled')
&& $this->containsLiquidForLoop($template);
if ($useExternalRenderer) {
// Use external Ruby liquid renderer
return $this->renderWithExternalLiquidRenderer($template, $variables);
}
// Use the Liquid template engine to resolve variables // Use the Liquid template engine to resolve variables
$environment = App::make('liquid.environment'); $environment = App::make('liquid.environment');
$environment->filterRegistry->register(StandardFilters::class); $environment->filterRegistry->register(StandardFilters::class);
@ -417,20 +420,10 @@ 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,
@ -442,10 +435,10 @@ class Plugin extends Model
'timestamp_utc' => now()->utc()->timestamp, 'timestamp_utc' => now()->utc()->timestamp,
], ],
'user' => [ 'user' => [
'utc_offset' => $utcOffset, 'utc_offset' => '0',
'name' => $this->user->name ?? 'Unknown User', 'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en', 'locale' => 'en',
'time_zone_iana' => $timezone, 'time_zone_iana' => config('app.timezone'),
], ],
'plugin_settings' => [ 'plugin_settings' => [
'instance_name' => $this->name, 'instance_name' => $this->name,
@ -528,30 +521,17 @@ class Plugin extends Model
if ($this->render_markup_view) { if ($this->render_markup_view) {
if ($standalone) { if ($standalone) {
$renderedView = view($this->render_markup_view, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
])->render();
if ($size === 'full') {
return view('trmnl-layouts.single', [ return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(), 'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og', 'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed, 'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode, 'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(), 'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedView, 'slot' => view($this->render_markup_view, [
])->render(); 'size' => $size,
} 'data' => $this->data_payload,
'config' => $this->configuration ?? [],
return view('trmnl-layouts.mashup', [ ])->render(),
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'slot' => $renderedView,
])->render(); ])->render();
} }
@ -582,61 +562,4 @@ 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) {
// 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);
}
} }

View file

@ -8,13 +8,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable // implements MustVerifyEmail class User extends Authenticatable // implements MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; use HasApiTokens, HasFactory, Notifiable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -28,7 +27,6 @@ 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',
]; ];
/** /**

View file

@ -3,7 +3,6 @@
namespace App\Providers; namespace App\Providers;
use App\Services\OidcProvider; use App\Services\OidcProvider;
use App\Services\QrCodeService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@ -16,7 +15,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind('qr-code', fn () => new QrCodeService); //
} }
/** /**

View file

@ -1,72 +0,0 @@
<?php
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
}
/**
* Configure Fortify actions.
*/
private function configureActions(): void
{
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::createUsersUsing(CreateNewUser::class);
}
/**
* Configure Fortify views.
*/
private function configureViews(): void
{
Fortify::loginView(fn (): Factory|View => view('pages::auth.login'));
Fortify::verifyEmailView(fn (): Factory|View => view('pages::auth.verify-email'));
Fortify::twoFactorChallengeView(fn (): Factory|View => view('pages::auth.two-factor-challenge'));
Fortify::confirmPasswordView(fn (): Factory|View => view('pages::auth.confirm-password'));
Fortify::registerView(fn (): Factory|View => view('pages::auth.register'));
Fortify::resetPasswordView(fn (): Factory|View => view('pages::auth.reset-password'));
Fortify::requestPasswordResetLinkView(fn (): Factory|View => view('pages::auth.forgot-password'));
}
/**
* Configure rate limiting.
*/
private function configureRateLimiting(): void
{
RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id')));
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Livewire\Volt\Volt;
class VoltServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
Volt::mount([
config('livewire.view_path', resource_path('views/livewire')),
resource_path('views/pages'),
]);
}
}

View file

@ -25,45 +25,12 @@ class ImageGenerationService
{ {
public static function generateImage(string $markup, $deviceId): string public static function generateImage(string $markup, $deviceId): string
{ {
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId); $device = Device::with('deviceModel')->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 or Device (for legacy devices) // Get image generation settings from DeviceModel if available, otherwise use device settings
$imageSettings = $deviceModel instanceof DeviceModel $imageSettings = self::getImageSettings($device);
? self::getImageSettingsFromModel($deviceModel)
: ($device instanceof 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);
@ -77,10 +44,6 @@ 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'])
@ -98,14 +61,6 @@ 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'])
@ -117,11 +72,6 @@ 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) {
@ -140,7 +90,8 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath); throw new RuntimeException('Image file is empty: '.$outputPath);
} }
Log::info("Generated image: $uuid"); $device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid; return $uuid;
@ -157,7 +108,22 @@ class ImageGenerationService
{ {
// If device has a DeviceModel, use its settings // If device has a DeviceModel, use its settings
if ($device->deviceModel) { if ($device->deviceModel) {
return self::getImageSettingsFromModel($device->deviceModel); /** @var DeviceModel $model */
$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
@ -181,43 +147,6 @@ class ImageGenerationService
]; ];
} }
/**
* Get image generation settings from a DeviceModel
*/
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{
if ($deviceModel instanceof 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
*/ */
@ -334,10 +263,6 @@ 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 {
@ -369,7 +294,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', 'error'])) { if (! in_array($imageType, ['setup-logo', 'sleep'])) {
return null; return null;
} }
@ -403,19 +328,16 @@ 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 $pluginName = null): string public static function generateDefaultScreenImage(Device $device, string $imageType): string
{ {
// Validate image type // Validate image type
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { if (! in_array($imageType, ['setup-logo', 'sleep'])) {
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);
@ -423,7 +345,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, $pluginName); $html = self::generateDefaultScreenHtml($device, $imageType);
// Create custom Browsershot instance if using AWS Lambda // Create custom Browsershot instance if using AWS Lambda
$browsershotInstance = null; $browsershotInstance = null;
@ -434,10 +356,6 @@ 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'])
@ -454,14 +372,6 @@ 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'])
@ -473,11 +383,6 @@ 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();
@ -503,13 +408,12 @@ 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 $pluginName = null): string private static function generateDefaultScreenHtml(Device $device, string $imageType): 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}")
}; };
@ -520,22 +424,14 @@ 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
// Build view data // Render the Blade template
$viewData = [ return view($templateName, [
'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();
} }
} }

View file

@ -1,111 +0,0 @@
<?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;
}
}

View file

@ -1,26 +0,0 @@
<?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'];
}
}
}

View file

@ -1,15 +0,0 @@
<?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;
}

View file

@ -1,31 +0,0 @@
<?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;
}
}

View file

@ -1,46 +0,0 @@
<?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;
}
}

View file

@ -17,34 +17,6 @@ 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((string) $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((string) $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
* *
@ -75,55 +47,32 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) // Find the required files (settings.yml and full.liquid/full.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']) { if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
}
// 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);
// Determine which template file to use and read its content // Read full.liquid content
$templatePath = null; $fullLiquid = File::get($filePaths['fullLiquidPath']);
$markupLanguage = 'blade';
if ($filePaths['fullLiquidPath']) { // Prepend shared.liquid content if available
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']); $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid; $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 // Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade'; $markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -131,9 +80,6 @@ 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'],
@ -159,7 +105,7 @@ class PluginImportService
: null, : null,
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
]); ]);
@ -195,12 +141,11 @@ 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, bool $allowDuplicate = false): Plugin public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
{ {
// Download the ZIP file // Download the ZIP file
$response = Http::timeout(60)->get($zipUrl); $response = Http::timeout(60)->get($zipUrl);
@ -228,55 +173,32 @@ class PluginImportService
$zip->extractTo($tempDir); $zip->extractTo($tempDir);
$zip->close(); $zip->close();
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php) // Find the required files (settings.yml and full.liquid/full.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']) { if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.'); throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are 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);
// Determine which template file to use and read its content // Read full.liquid content
$templatePath = null; $fullLiquid = File::get($filePaths['fullLiquidPath']);
$markupLanguage = 'blade';
if ($filePaths['fullLiquidPath']) { // Prepend shared.liquid content if available
$templatePath = $filePaths['fullLiquidPath'];
$fullLiquid = File::get($templatePath);
// Prepend shared.liquid or shared.blade.php content if available
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) { if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
$sharedLiquid = File::get($filePaths['sharedLiquidPath']); $sharedLiquid = File::get($filePaths['sharedLiquidPath']);
$fullLiquid = $sharedLiquid."\n".$fullLiquid; $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 // Check if the file ends with .liquid to set markup language
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
}
} elseif ($filePaths['sharedLiquidPath']) {
$templatePath = $filePaths['sharedLiquidPath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} elseif ($filePaths['sharedBladePath']) {
$templatePath = $filePaths['sharedBladePath'];
$fullLiquid = File::get($templatePath);
$markupLanguage = 'blade'; $markupLanguage = 'blade';
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
$markupLanguage = 'liquid';
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
} }
// Ensure custom_fields is properly formatted // Ensure custom_fields is properly formatted
@ -284,34 +206,22 @@ 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'],
]; ];
// Determine the trmnlp_id to use $plugin_updated = isset($settings['id'])
$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' => $trmnlpId, 'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
], ],
[ [
'user_id' => $user->id, 'user_id' => $user->id,
'name' => $settings['name'] ?? 'Imported Plugin', 'name' => $settings['name'] ?? 'Imported Plugin',
'trmnlp_id' => $trmnlpId, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
'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,
@ -321,7 +231,7 @@ class PluginImportService
: null, : null,
'polling_body' => $settings['polling_body'] ?? null, 'polling_body' => $settings['polling_body'] ?? null,
'markup_language' => $markupLanguage, 'markup_language' => $markupLanguage,
'render_markup' => $fullLiquid ?? null, 'render_markup' => $fullLiquid,
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer, 'preferred_renderer' => $preferredRenderer,
@ -356,7 +266,6 @@ 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) {
@ -374,8 +283,6 @@ 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';
} }
} }
@ -391,18 +298,15 @@ 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 || $sharedLiquidPath || $sharedBladePath)) { if ($settingsYamlPath && $fullLiquidPath) {
return [ return [
'settingsYamlPath' => $settingsYamlPath, 'settingsYamlPath' => $settingsYamlPath,
'fullLiquidPath' => $fullLiquidPath, 'fullLiquidPath' => $fullLiquidPath,
'sharedLiquidPath' => $sharedLiquidPath, 'sharedLiquidPath' => $sharedLiquidPath,
'sharedBladePath' => $sharedBladePath,
]; ];
} }
} }
@ -419,11 +323,9 @@ class PluginImportService
$fullLiquidPath = $tempDir.'/src/full.blade.php'; $fullLiquidPath = $tempDir.'/src/full.blade.php';
} }
// Check for shared.liquid or shared.blade.php in the same directory // Check for shared.liquid 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
@ -440,24 +342,17 @@ 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;
}
} }
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid // If we found both required files, break the loop
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) { if ($settingsYamlPath && $fullLiquidPath) {
$fullLiquidDir = dirname((string) $fullLiquidPath); break;
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 || $sharedLiquidPath || $sharedBladePath)) { if ($settingsYamlPath && $fullLiquidPath) {
// 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);
@ -468,25 +363,17 @@ 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 full.liquid or full.blade.php if it exists // Copy shared.liquid 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';
} }
} }
} }
@ -495,53 +382,9 @@ 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
* *

View file

@ -1,147 +0,0 @@
<?php
namespace App\Services;
use BaconQrCode\Common\ErrorCorrectionLevel;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use InvalidArgumentException;
/**
* QR Code generation service using bacon/bacon-qr-code
*/
class QrCodeService
{
protected ?string $format = null;
protected ?int $size = null;
protected ?string $errorCorrection = null;
/**
* Set the output format
*
* @param string $format The format (currently only 'svg' is supported)
* @return $this
*/
public function format(string $format): self
{
$this->format = $format;
return $this;
}
/**
* Set the size of the QR code
*
* @param int $size The size in pixels
* @return $this
*/
public function size(int $size): self
{
$this->size = $size;
return $this;
}
/**
* Set the error correction level
*
* @param string $level Error correction level: 'l', 'm', 'q', 'h'
* @return $this
*/
public function errorCorrection(string $level): self
{
$this->errorCorrection = $level;
return $this;
}
/**
* Generate the QR code
*
* @param string $text The text to encode
* @return string The generated QR code (SVG string)
*/
public function generate(string $text): string
{
// Ensure format is set (default to SVG)
$format = $this->format ?? 'svg';
if ($format !== 'svg') {
throw new InvalidArgumentException("Format '{$format}' is not supported. Only 'svg' is currently supported.");
}
// Calculate size and margin
// If size is not set, calculate from module size (default module size is 11)
if ($this->size === null) {
$moduleSize = 11;
$this->size = 29 * $moduleSize;
}
// Calculate margin: 4 modules on each side
// Module size = size / 29, so margin = (size / 29) * 4
$moduleSize = $this->size / 29;
$margin = (int) ($moduleSize * 4);
// Map error correction level
$errorCorrectionLevel = ErrorCorrectionLevel::valueOf('M'); // default
if ($this->errorCorrection !== null) {
$errorCorrectionLevel = match (mb_strtoupper($this->errorCorrection)) {
'L' => ErrorCorrectionLevel::valueOf('L'),
'M' => ErrorCorrectionLevel::valueOf('M'),
'Q' => ErrorCorrectionLevel::valueOf('Q'),
'H' => ErrorCorrectionLevel::valueOf('H'),
default => ErrorCorrectionLevel::valueOf('M'),
};
}
// Create renderer style with size and margin
$rendererStyle = new RendererStyle($this->size, $margin);
// Create SVG renderer
$renderer = new ImageRenderer(
$rendererStyle,
new SvgImageBackEnd()
);
// Create writer
$writer = new Writer($renderer);
// Generate SVG
$svg = $writer->writeString($text, 'ISO-8859-1', $errorCorrectionLevel);
// Add class="qr-code" to the SVG element
$svg = $this->addQrCodeClass($svg);
return $svg;
}
/**
* Add the 'qr-code' class to the SVG element
*
* @param string $svg The SVG string
* @return string The SVG string with the class added
*/
protected function addQrCodeClass(string $svg): string
{
// Match <svg followed by whitespace or attributes, and insert class before the first attribute or closing >
if (preg_match('/<svg\s+([^>]*)>/', $svg, $matches)) {
$attributes = $matches[1];
// Check if class already exists
if (mb_strpos($attributes, 'class=') === false) {
$svg = preg_replace('/<svg\s+([^>]*)>/', '<svg class="qr-code" $1>', $svg, 1);
} else {
// If class exists, add qr-code to it
$svg = preg_replace('/(<svg\s+[^>]*class=["\'])([^"\']*)(["\'][^>]*>)/', '$1$2 qr-code$3', $svg, 1);
}
} else {
// Fallback: simple replacement if no attributes
$svg = preg_replace('/<svg>/', '<svg class="qr-code">', $svg, 1);
}
return $svg;
}
}

15
boost.json Normal file
View file

@ -0,0 +1,15 @@
{
"agents": [
"claude_code",
"copilot",
"cursor",
"phpstorm"
],
"editors": [
"claude_code",
"cursor",
"phpstorm",
"vscode"
],
"guidelines": []
}

View file

@ -2,5 +2,6 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FolioServiceProvider::class,
App\Providers\VoltServiceProvider::class,
]; ];

View file

@ -6,7 +6,6 @@
"keywords": [ "keywords": [
"trmnl", "trmnl",
"trmnl-server", "trmnl-server",
"trmnl-byos",
"laravel" "laravel"
], ],
"license": "MIT", "license": "MIT",
@ -15,19 +14,16 @@
"ext-imagick": "*", "ext-imagick": "*",
"ext-simplexml": "*", "ext-simplexml": "*",
"ext-zip": "*", "ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "2.1.*", "bnussbau/laravel-trmnl-blade": "2.0.*",
"bnussbau/trmnl-pipeline-php": "^0.6.0", "bnussbau/trmnl-pipeline-php": "^0.4.0",
"keepsuit/laravel-liquid": "^0.5.2", "keepsuit/laravel-liquid": "^0.5.2",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.1", "laravel/framework": "^12.1",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",
"laravel/socialite": "^5.23", "laravel/socialite": "^5.23",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0", "livewire/flux": "^2.0",
"livewire/livewire": "^4.0", "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"
}, },

1904
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -130,7 +130,7 @@ return [
'force_https' => env('FORCE_HTTPS', false), 'force_https' => env('FORCE_HTTPS', false),
'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true), 'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false),
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null), 'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
'notifications' => [ 'notifications' => [

View file

@ -104,7 +104,7 @@ return [
| Password Confirmation Timeout | Password Confirmation Timeout
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Here you may define the number of seconds before a password confirmation | Here you may define the amount of seconds before a password confirmation
| window expires and users are asked to re-enter their password via the | window expires and users are asked to re-enter their password via the
| confirmation screen. By default, the timeout lasts for three hours. | confirmation screen. By default, the timeout lasts for three hours.
| |

View file

@ -27,8 +27,7 @@ return [
| same cache driver to group types of items stored in your caches. | same cache driver to group types of items stored in your caches.
| |
| Supported drivers: "array", "database", "file", "memcached", | Supported drivers: "array", "database", "file", "memcached",
| "redis", "dynamodb", "octane", | "redis", "dynamodb", "octane", "null"
| "failover", "null"
| |
*/ */
@ -91,14 +90,6 @@ return [
'driver' => 'octane', 'driver' => 'octane',
], ],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
], ],
/* /*
@ -112,6 +103,6 @@ return [
| |
*/ */
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
]; ];

View file

@ -40,7 +40,6 @@ return [
'busy_timeout' => null, 'busy_timeout' => null,
'journal_mode' => null, 'journal_mode' => null,
'synchronous' => null, 'synchronous' => null,
'transaction_mode' => 'DEFERRED',
], ],
'mysql' => [ 'mysql' => [
@ -59,7 +58,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -79,7 +78,7 @@ return [
'strict' => true, 'strict' => true,
'engine' => null, 'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([ 'options' => extension_loaded('pdo_mysql') ? array_filter([
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'), PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [], ]) : [],
], ],
@ -95,7 +94,7 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => 'prefer',
], ],
'sqlsrv' => [ 'sqlsrv' => [
@ -148,7 +147,7 @@ return [
'options' => [ 'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'), 'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
'persistent' => env('REDIS_PERSISTENT', false), 'persistent' => env('REDIS_PERSISTENT', false),
], ],
@ -159,10 +158,6 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'), 'database' => env('REDIS_DB', '0'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
'cache' => [ 'cache' => [
@ -172,10 +167,6 @@ return [
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'), 'database' => env('REDIS_CACHE_DB', '1'),
'max_retries' => env('REDIS_MAX_RETRIES', 3),
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
], ],
], ],

View file

@ -35,16 +35,14 @@ return [
'root' => storage_path('app/private'), 'root' => storage_path('app/private'),
'serve' => true, 'serve' => true,
'throw' => false, 'throw' => false,
'report' => false,
], ],
'public' => [ 'public' => [
'driver' => 'local', 'driver' => 'local',
'root' => storage_path('app/public'), 'root' => storage_path('app/public'),
'url' => mb_rtrim(env('APP_URL'), '/').'/storage', 'url' => env('APP_URL').'/storage',
'visibility' => 'public', 'visibility' => 'public',
'throw' => false, 'throw' => false,
'report' => false,
], ],
's3' => [ 's3' => [
@ -57,7 +55,6 @@ return [
'endpoint' => env('AWS_ENDPOINT'), 'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false, 'throw' => false,
'report' => false,
], ],
], ],

View file

@ -1,159 +0,0 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => null,
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
config('app.registration.enabled') && Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
// Features::updateProfileInformation(),
// Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
];

View file

@ -1,277 +0,0 @@
<?php
return [
/*
|---------------------------------------------------------------------------
| Component Locations
|---------------------------------------------------------------------------
|
| This value sets the root directories that'll be used to resolve view-based
| components like single and multi-file components. The make command will
| use the first directory in this array to add new component files to.
|
*/
'component_locations' => [
resource_path('views/components'),
resource_path('views/livewire'),
],
/*
|---------------------------------------------------------------------------
| Component Namespaces
|---------------------------------------------------------------------------
|
| This value sets default namespaces that will be used to resolve view-based
| components like single-file and multi-file components. These folders'll
| also be referenced when creating new components via the make command.
|
*/
'component_namespaces' => [
'layouts' => resource_path('views/layouts'),
'pages' => resource_path('views/pages'),
],
/*
|---------------------------------------------------------------------------
| Page Layout
|---------------------------------------------------------------------------
| The view that will be used as the layout when rendering a single component as
| an entire page via `Route::livewire('/post/create', 'pages::create-post')`.
| In this case, the content of pages::create-post will render into $slot.
|
*/
'component_layout' => 'layouts::app',
/*
|---------------------------------------------------------------------------
| Lazy Loading Placeholder
|---------------------------------------------------------------------------
| Livewire allows you to lazy load components that would otherwise slow down
| the initial page load. Every component can have a custom placeholder or
| you can define the default placeholder view for all components below.
|
*/
'component_placeholder' => null, // Example: 'placeholders::skeleton'
/*
|---------------------------------------------------------------------------
| Make Command
|---------------------------------------------------------------------------
| This value determines the default configuration for the artisan make command
| You can configure the component type (sfc, mfc, class) and whether to use
| the high-voltage () emoji as a prefix in the sfc|mfc component names.
|
*/
'make_command' => [
'type' => 'sfc', // Options: 'sfc', 'mfc', 'class'
'emoji' => false, // Options: true, false
],
/*
|---------------------------------------------------------------------------
| Class Namespace
|---------------------------------------------------------------------------
|
| This value sets the root class namespace for Livewire component classes in
| your application. This value will change where component auto-discovery
| finds components. It's also referenced by the file creation commands.
|
*/
'class_namespace' => 'App\\Livewire',
/*
|---------------------------------------------------------------------------
| Class Path
|---------------------------------------------------------------------------
|
| This value is used to specify the path where Livewire component class files
| are created when running creation commands like `artisan make:livewire`.
| This path is customizable to match your projects directory structure.
|
*/
'class_path' => app_path('Livewire'),
/*
|---------------------------------------------------------------------------
| View Path
|---------------------------------------------------------------------------
|
| This value is used to specify where Livewire component Blade templates are
| stored when running file creation commands like `artisan make:livewire`.
| It is also used if you choose to omit a component's render() method.
|
*/
'view_path' => resource_path('views/livewire'),
/*
|---------------------------------------------------------------------------
| Temporary File Uploads
|---------------------------------------------------------------------------
|
| Livewire handles file uploads by storing uploads in a temporary directory
| before the file is stored permanently. All file uploads are directed to
| a global endpoint for temporary storage. You may configure this below:
|
*/
'temporary_file_upload' => [
'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default'
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB)
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp'
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1'
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs...
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma',
],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated...
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs...
],
/*
|---------------------------------------------------------------------------
| Render On Redirect
|---------------------------------------------------------------------------
|
| This value determines if Livewire will run a component's `render()` method
| after a redirect has been triggered using something like `redirect(...)`
| Setting this to true will render the view once more before redirecting
|
*/
'render_on_redirect' => false,
/*
|---------------------------------------------------------------------------
| Eloquent Model Binding
|---------------------------------------------------------------------------
|
| Previous versions of Livewire supported binding directly to eloquent model
| properties using wire:model by default. However, this behavior has been
| deemed too "magical" and has therefore been put under a feature flag.
|
*/
'legacy_model_binding' => false,
/*
|---------------------------------------------------------------------------
| Auto-inject Frontend Assets
|---------------------------------------------------------------------------
|
| By default, Livewire automatically injects its JavaScript and CSS into the
| <head> and <body> of pages containing Livewire components. By disabling
| this behavior, you need to use @livewireStyles and @livewireScripts.
|
*/
'inject_assets' => true,
/*
|---------------------------------------------------------------------------
| Navigate (SPA mode)
|---------------------------------------------------------------------------
|
| By adding `wire:navigate` to links in your Livewire application, Livewire
| will prevent the default link handling and instead request those pages
| via AJAX, creating an SPA-like effect. Configure this behavior here.
|
*/
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#E05B45',
],
/*
|---------------------------------------------------------------------------
| HTML Morph Markers
|---------------------------------------------------------------------------
|
| Livewire intelligently "morphs" existing HTML into the newly rendered HTML
| after each update. To make this process more reliable, Livewire injects
| "markers" into the rendered Blade surrounding @if, @class & @foreach.
|
*/
'inject_morph_markers' => true,
/*
|---------------------------------------------------------------------------
| Smart Wire Keys
|---------------------------------------------------------------------------
|
| Livewire uses loops and keys used within loops to generate smart keys that
| are applied to nested components that don't have them. This makes using
| nested components more reliable by ensuring that they all have keys.
|
*/
'smart_wire_keys' => true,
/*
|---------------------------------------------------------------------------
| Pagination Theme
|---------------------------------------------------------------------------
|
| When enabling Livewire's pagination feature by using the `WithPagination`
| trait, Livewire will use Tailwind templates to render pagination views
| on the page. If you want Bootstrap CSS, you can specify: "bootstrap"
|
*/
'pagination_theme' => 'tailwind',
/*
|---------------------------------------------------------------------------
| Release Token
|---------------------------------------------------------------------------
|
| This token is stored client-side and sent along with each request to check
| a users session to see if a new release has invalidated it. If there is
| a mismatch it will throw an error and prompt for a browser refresh.
|
*/
'release_token' => 'a',
/*
|---------------------------------------------------------------------------
| CSP Safe
|---------------------------------------------------------------------------
|
| This config is used to determine if Livewire will use the CSP-safe version
| of Alpine in its bundle. This is useful for applications that are using
| strict Content Security Policy (CSP) to protect against XSS attacks.
|
*/
'csp_safe' => false,
/*
|---------------------------------------------------------------------------
| Payload Guards
|---------------------------------------------------------------------------
|
| These settings protect against malicious or oversized payloads that could
| cause denial of service. The default values should feel reasonable for
| most web applications. Each can be set to null to disable the limit.
|
*/
'payload' => [
'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes
'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths
'max_calls' => 50, // Maximum method calls per request
'max_components' => 20, // Maximum components per batch request
],
];

View file

@ -54,7 +54,7 @@ return [
'stack' => [ 'stack' => [
'driver' => 'stack', 'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')), 'channels' => explode(',', env('LOG_STACK', 'single')),
'ignore_exceptions' => false, 'ignore_exceptions' => false,
], ],
@ -98,10 +98,10 @@ return [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'handler_with' => [ 'formatter' => env('LOG_STDOUT_FORMATTER'),
'with' => [
'stream' => 'php://stdout', 'stream' => 'php://stdout',
], ],
'formatter' => env('LOG_STDOUT_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],
@ -109,10 +109,10 @@ return [
'driver' => 'monolog', 'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'), 'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class, 'handler' => StreamHandler::class,
'handler_with' => [ 'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr', 'stream' => 'php://stderr',
], ],
'formatter' => env('LOG_STDERR_FORMATTER'),
'processors' => [PsrLogMessageProcessor::class], 'processors' => [PsrLogMessageProcessor::class],
], ],

View file

@ -46,7 +46,7 @@ return [
'username' => env('MAIL_USERNAME'), 'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, 'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
], ],
'ses' => [ 'ses' => [
@ -85,7 +85,6 @@ return [
'smtp', 'smtp',
'log', 'log',
], ],
'retry_after' => 60,
], ],
'roundrobin' => [ 'roundrobin' => [
@ -94,7 +93,6 @@ return [
'ses', 'ses',
'postmark', 'postmark',
], ],
'retry_after' => 60,
], ],
], ],

View file

@ -24,8 +24,7 @@ return [
| used by your application. An example configuration is provided for | used by your application. An example configuration is provided for
| each backend supported by Laravel. You're also free to add more. | each backend supported by Laravel. You're also free to add more.
| |
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis", | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null"
| "deferred", "background", "failover", "null"
| |
*/ */
@ -73,22 +72,6 @@ return [
'after_commit' => false, 'after_commit' => false,
], ],
'deferred' => [
'driver' => 'deferred',
],
'background' => [
'driver' => 'background',
],
'failover' => [
'driver' => 'failover',
'connections' => [
'database',
'deferred',
],
],
], ],
/* /*

View file

@ -15,11 +15,7 @@ return [
*/ */
'postmark' => [ 'postmark' => [
'key' => env('POSTMARK_API_KEY'), 'token' => env('POSTMARK_TOKEN'),
],
'resend' => [
'key' => env('RESEND_API_KEY'),
], ],
'ses' => [ 'ses' => [
@ -28,6 +24,10 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
], ],
'resend' => [
'key' => env('RESEND_KEY'),
],
'slack' => [ 'slack' => [
'notifications' => [ 'notifications' => [
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),

View file

@ -13,8 +13,8 @@ return [
| incoming requests. Laravel supports a variety of storage options to | incoming requests. Laravel supports a variety of storage options to
| persist session data. Database storage is a great default choice. | persist session data. Database storage is a great default choice.
| |
| Supported: "file", "cookie", "database", "memcached", | Supported: "file", "cookie", "database", "apc",
| "redis", "dynamodb", "array" | "memcached", "redis", "dynamodb", "array"
| |
*/ */
@ -32,7 +32,7 @@ return [
| |
*/ */
'lifetime' => (int) env('SESSION_LIFETIME', 120), 'lifetime' => env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
@ -97,7 +97,7 @@ return [
| define the cache store which should be used to store the session data | define the cache store which should be used to store the session data
| between requests. This must match one of your defined cache stores. | between requests. This must match one of your defined cache stores.
| |
| Affects: "dynamodb", "memcached", "redis" | Affects: "apc", "dynamodb", "memcached", "redis"
| |
*/ */
@ -129,7 +129,7 @@ return [
'cookie' => env( 'cookie' => env(
'SESSION_COOKIE', 'SESSION_COOKIE',
Str::slug((string) env('APP_NAME', 'laravel')).'-session' Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
), ),
/* /*
@ -152,7 +152,7 @@ return [
| |
| This value determines the domain and subdomains the session cookie is | This value determines the domain and subdomains the session cookie is
| available to. By default, the cookie will be available to the root | available to. By default, the cookie will be available to the root
| domain without subdomains. Typically, this shouldn't be changed. | domain and all subdomains. Typically, this shouldn't be changed.
| |
*/ */

View file

@ -1,6 +0,0 @@
<?php
return [
// Commaseparated 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)),
];

View file

@ -1,38 +0,0 @@
<?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',
];
}
}

View file

@ -29,24 +29,8 @@ 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']),
]);
}
} }

View file

@ -29,9 +29,7 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'two_factor_secret' => null, 'assign_new_devices' => false,
'two_factor_recovery_codes' => null,
'two_factor_confirmed_at' => null,
]; ];
} }
@ -44,16 +42,4 @@ class UserFactory extends Factory
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }
/**
* Indicate that the model has two-factor authentication configured.
*/
public function withTwoFactor(): static
{
return $this->state(fn (array $attributes) => [
'two_factor_secret' => encrypt('secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])),
'two_factor_confirmed_at' => now(),
]);
}
} }

View file

@ -22,7 +22,6 @@ 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');
}); });
} }

View file

@ -1,33 +0,0 @@
<?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');
}
};

View file

@ -1,29 +0,0 @@
<?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');
});
}
};

View file

@ -1,29 +0,0 @@
<?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');
});
}
};

View file

@ -1,124 +0,0 @@
<?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]);
}
};

View file

@ -1,28 +0,0 @@
<?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');
});
}
};

View file

@ -1,28 +0,0 @@
<?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');
});
}
};

View file

@ -1,33 +0,0 @@
<?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');
});
}
};

View file

@ -1,58 +0,0 @@
<?php
use App\Models\Plugin;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Find and handle duplicate (user_id, trmnlp_id) combinations
$duplicates = Plugin::query()
->selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count')
->whereNotNull('trmnlp_id')
->groupBy('user_id', 'trmnlp_id')
->havingRaw('COUNT(*) > ?', [1])
->get();
// For each duplicate combination, keep the first one (by id) and set others to null
foreach ($duplicates as $duplicate) {
$plugins = Plugin::query()
->where('user_id', $duplicate->user_id)
->where('trmnlp_id', $duplicate->trmnlp_id)
->orderBy('id')
->get();
// Keep the first one, set the rest to null
$keepFirst = true;
foreach ($plugins as $plugin) {
if ($keepFirst) {
$keepFirst = false;
continue;
}
$plugin->update(['trmnlp_id' => null]);
}
}
Schema::table('plugins', function (Blueprint $table) {
$table->unique(['user_id', 'trmnlp_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropUnique(['user_id', 'trmnlp_id']);
});
}
};

View file

@ -1,28 +0,0 @@
<?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');
});
}
};

View file

@ -1,34 +0,0 @@
<?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->text('two_factor_secret')->after('password')->nullable();
$table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable();
$table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View file

@ -13,8 +13,8 @@ class ExampleRecipesSeeder extends Seeder
public function run($user_id = 1): void public function run($user_id = 1): void
{ {
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec'],
[ [
'uuid' => '9e46c6cf-358c-4bfe-8998-436b3a207fec',
'name' => 'ÖBB Departures', 'name' => 'ÖBB Departures',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -32,8 +32,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '3b046eda-34e9-4232-b935-c33b989a284b'],
[ [
'uuid' => '3b046eda-34e9-4232-b935-c33b989a284b',
'name' => 'Weather', 'name' => 'Weather',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -51,8 +51,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54'],
[ [
'uuid' => '21464b16-5f5a-4099-a967-f5c915e3da54',
'name' => 'Zen Quotes', 'name' => 'Zen Quotes',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -70,8 +70,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f'],
[ [
'uuid' => '8d472959-400f-46ee-afb2-4a9f1cfd521f',
'name' => 'This Day in History', 'name' => 'This Day in History',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -89,8 +89,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d'],
[ [
'uuid' => '4349fdad-a273-450b-aa00-3d32f2de788d',
'name' => 'Home Assistant', 'name' => 'Home Assistant',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -108,8 +108,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80'],
[ [
'uuid' => 'be5f7e1f-3ad8-4d66-93b2-36f7d6dcbd80',
'name' => 'Sunrise/Sunset', 'name' => 'Sunrise/Sunset',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -127,8 +127,8 @@ class ExampleRecipesSeeder extends Seeder
); );
Plugin::updateOrCreate( Plugin::updateOrCreate(
['uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe'],
[ [
'uuid' => '82d3ee14-d578-4969-bda5-2bbf825435fe',
'name' => 'Pollen Forecast', 'name' => 'Pollen Forecast',
'user_id' => $user_id, 'user_id' => $user_id,
'data_payload' => null, 'data_payload' => null,
@ -144,42 +144,5 @@ 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

File diff suppressed because it is too large Load diff

View file

@ -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.30.0", "puppeteer": "24.17.0",
"tailwindcss": "^4.0.7", "tailwindcss": "^4.0.7",
"vite": "^7.0.4" "vite": "^7.0.4"
}, },

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,521 +0,0 @@
<!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>

View file

@ -1,7 +0,0 @@
{
"name": "TRMNL BYOS Laravel Mirror",
"short_name": "TRMNL BYOS",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#ffffff"
}

View file

@ -59,10 +59,6 @@
@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] {
@ -72,39 +68,3 @@ 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;
}
}

View file

@ -1,9 +1,8 @@
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, selectAll } from '@codemirror/commands'; import { indentWithTab } 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';
@ -155,16 +154,7 @@ export function createCodeMirror(element, options = {}) {
createResizePlugin(), createResizePlugin(),
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]), ...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
...themeSupport, ...themeSupport,
keymap.of([ keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
indentWithTab,
...foldKeymap,
...historyKeymap,
...searchKeymap,
{
key: 'Mod-a',
run: selectAll,
},
]),
EditorView.theme({ EditorView.theme({
'&': { '&': {
fontSize: '14px', fontSize: '14px',

View file

@ -8,7 +8,7 @@
x-show.transition.out.opacity.duration.1500ms="shown" x-show.transition.out.opacity.duration.1500ms="shown"
x-transition:leave.opacity.duration.1500ms x-transition:leave.opacity.duration.1500ms
style="display: none" style="display: none"
{{ $attributes->merge(['class' => 'text-sm']) }} {{ $attributes->merge(['class' => 'text-sm text-gray-600']) }}
> >
{{ $slot->isEmpty() ? __('Saved.') : $slot }} {{ $slot->isEmpty() ? __('Saved.') : $slot }}
</div> </div>

View file

@ -3,7 +3,7 @@
'description', 'description',
]) ])
<div class="flex w-full flex-col text-center"> <div class="flex w-full flex-col gap-2 text-center">
<flux:heading size="xl">{{ $title }}</flux:heading> <h1 class="text-xl font-medium dark:text-zinc-200">{{ $title }}</h1>
<flux:subheading>{{ $description }}</flux:subheading> <p class="text-center text-sm dark:text-zinc-400">{{ $description }}</p>
</div> </div>

View file

@ -1,39 +0,0 @@
<flux:dropdown position="bottom" align="start">
<flux:sidebar.profile
{{ $attributes->only('name') }}
:initials="auth()->user()->initials()"
icon:trailing="chevrons-up-down"
data-test="sidebar-menu-button"
/>
<flux:menu>
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu.radio.group>
</flux:menu>
</flux:dropdown>

View file

@ -1,5 +1,5 @@
<x-layouts::app.header> <x-layouts.app.header>
<flux:main> <flux:main>
{{ $slot }} {{ $slot }}
</flux:main> </flux:main>
</x-layouts::app.header> </x-layouts.app.header>

View file

@ -0,0 +1,3 @@
<x-layouts.auth.card>
{{ $slot }}
</x-layouts.auth.card>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-md flex-col gap-6"> <div class="flex w-full max-w-md flex-col gap-6">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate> <a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 items-center justify-center rounded-md"> <span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>

View file

@ -6,7 +6,7 @@
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10"> <div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
<div class="flex w-full max-w-sm flex-col gap-2"> <div class="flex w-full max-w-sm flex-col gap-2">
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate> <a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium">
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md"> <span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>

View file

@ -5,11 +5,11 @@
</head> </head>
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900"> <body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0"> <div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800"> <div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-r dark:border-neutral-800">
<div class="absolute inset-0 bg-neutral-900"></div> <div class="absolute inset-0 bg-neutral-900"></div>
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate> <a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium">
<span class="flex h-10 w-10 items-center justify-center rounded-md"> <span class="flex h-10 w-10 items-center justify-center rounded-md">
<x-app-logo-icon class="me-2 h-7 fill-current text-white" /> <x-app-logo-icon class="mr-2 h-7 fill-current text-white" />
</span> </span>
{{ config('app.name', 'Laravel') }} {{ config('app.name', 'Laravel') }}
</a> </a>
@ -20,14 +20,14 @@
<div class="relative z-20 mt-auto"> <div class="relative z-20 mt-auto">
<blockquote class="space-y-2"> <blockquote class="space-y-2">
<flux:heading size="lg">&ldquo;{{ trim($message) }}&rdquo;</flux:heading> <p class="text-lg">&ldquo;{{ trim($message) }}&rdquo;</p>
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer> <footer class="text-sm">{{ trim($author) }}</footer>
</blockquote> </blockquote>
</div> </div>
</div> </div>
<div class="w-full lg:p-8"> <div class="w-full lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> <div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate> <a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden">
<span class="flex h-9 w-9 items-center justify-center rounded-md"> <span class="flex h-9 w-9 items-center justify-center rounded-md">
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" /> <x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
</span> </span>

View file

@ -0,0 +1,22 @@
<div class="flex items-start max-md:flex-col">
<div class="mr-10 w-full pb-4 md:w-[220px]">
<flux:navlist>
<flux:navlist.item href="{{ route('settings.preferences') }}" wire:navigate>Preferences</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.profile') }}" wire:navigate>Profile</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.password') }}" wire:navigate>Password</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.appearance') }}" wire:navigate>Appearance</flux:navlist.item>
<flux:navlist.item href="{{ route('settings.support') }}" wire:navigate>Support</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View file

@ -1,23 +0,0 @@
@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>

View file

@ -4,7 +4,7 @@
'heading' => null, 'heading' => null,
]) ])
<?php if ($expandable && $heading) { ?> <?php if ($expandable && $heading): ?>
<ui-disclosure <ui-disclosure
{{ $attributes->class('group/disclosure') }} {{ $attributes->class('group/disclosure') }}
@ -15,7 +15,7 @@
type="button" type="button"
class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white" class="group/disclosure-button mb-[2px] flex h-10 w-full items-center rounded-lg text-zinc-500 hover:bg-zinc-800/5 hover:text-zinc-800 lg:h-8 dark:text-white/80 dark:hover:bg-white/[7%] dark:hover:text-white"
> >
<div class="ps-3 pe-4"> <div class="pl-3 pr-4">
<flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" /> <flux:icon.chevron-down class="hidden size-3! group-data-open/disclosure-button:block" />
<flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" /> <flux:icon.chevron-right class="block size-3! group-data-open/disclosure-button:hidden" />
</div> </div>
@ -23,14 +23,14 @@
<span class="text-sm font-medium leading-none">{{ $heading }}</span> <span class="text-sm font-medium leading-none">{{ $heading }}</span>
</button> </button>
<div class="relative hidden space-y-[2px] ps-7 data-open:block" @if ($expanded === true) data-open @endif> <div class="relative hidden space-y-[2px] pl-7 data-open:block" @if ($expanded === true) data-open @endif>
<div class="absolute inset-y-[3px] start-0 ms-4 w-px bg-zinc-200 dark:bg-white/30"></div> <div class="absolute inset-y-[3px] left-0 ml-4 w-px bg-zinc-200 dark:bg-white/30"></div>
{{ $slot }} {{ $slot }}
</div> </div>
</ui-disclosure> </ui-disclosure>
<?php } elseif ($heading) { ?> <?php elseif ($heading): ?>
<div {{ $attributes->class('block space-y-[2px]') }}> <div {{ $attributes->class('block space-y-[2px]') }}>
<div class="px-1 py-2"> <div class="px-1 py-2">
@ -42,10 +42,10 @@
</div> </div>
</div> </div>
<?php } else { ?> <?php else: ?>
<div {{ $attributes->class('block space-y-[2px]') }}> <div {{ $attributes->class('block space-y-[2px]') }}>
{{ $slot }} {{ $slot }}
</div> </div>
<?php } ?> <?php endif; ?>

View file

@ -1,96 +0,0 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
@include('partials.head')
</head>
<body class="min-h-screen bg-white dark:bg-zinc-800">
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<flux:sidebar.header>
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
<flux:sidebar.collapse class="lg:hidden" />
</flux:sidebar.header>
<flux:sidebar.nav>
<flux:sidebar.group :heading="__('Platform')" class="grid">
<flux:sidebar.item icon="home" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
{{ __('Dashboard') }}
</flux:sidebar.item>
</flux:sidebar.group>
</flux:sidebar.nav>
<flux:spacer />
<flux:sidebar.nav>
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
{{ __('Repository') }}
</flux:sidebar.item>
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
{{ __('Documentation') }}
</flux:sidebar.item>
</flux:sidebar.nav>
<x-desktop-user-menu class="hidden lg:block" :name="auth()->user()->name" />
</flux:sidebar>
<!-- Mobile User Menu -->
<flux:header class="lg:hidden">
<flux:sidebar.toggle class="lg:hidden" icon="bars-2" inset="left" />
<flux:spacer />
<flux:dropdown position="top" align="end">
<flux:profile
:initials="auth()->user()->initials()"
icon-trailing="chevron-down"
/>
<flux:menu>
<flux:menu.radio.group>
<div class="p-0 text-sm font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
<flux:avatar
:name="auth()->user()->name"
:initials="auth()->user()->initials()"
/>
<div class="grid flex-1 text-start text-sm leading-tight">
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
</div>
</div>
</div>
</flux:menu.radio.group>
<flux:menu.separator />
<flux:menu.radio.group>
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
{{ __('Settings') }}
</flux:menu.item>
</flux:menu.radio.group>
<flux:menu.separator />
<form method="POST" action="{{ route('logout') }}" class="w-full">
@csrf
<flux:menu.item
as="button"
type="submit"
icon="arrow-right-start-on-rectangle"
class="w-full cursor-pointer"
data-test="logout-button"
>
{{ __('Log Out') }}
</flux:menu.item>
</form>
</flux:menu>
</flux:dropdown>
</flux:header>
{{ $slot }}
@fluxScripts
</body>
</html>

View file

@ -1,3 +0,0 @@
<x-layouts::auth.simple :title="$title ?? null">
{{ $slot }}
</x-layouts::auth.simple>

View file

@ -0,0 +1,61 @@
<?php
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $password = '';
/**
* Confirm the current user's password.
*/
public function confirmPassword(): void
{
$this->validate([
'password' => ['required', 'string'],
]);
if (! Auth::guard('web')->validate([
'email' => Auth::user()->email,
'password' => $this->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
session(['auth.password_confirmed_at' => time()]);
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header
title="Confirm password"
description="This is a secure area of the application. Please confirm your password before continuing."
/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="confirmPassword" class="flex flex-col gap-6">
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Confirm') }}</flux:button>
</form>
</div>

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Support\Facades\Password;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $email = '';
/**
* Send a password reset link to the provided email address.
*/
public function sendPasswordResetLink(): void
{
$this->validate([
'email' => ['required', 'string', 'email'],
]);
Password::sendResetLink($this->only('email'));
session()->flash('status', __('A reset link will be sent if the account exists.'));
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Forgot password" description="Enter your email to receive a password reset link" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="sendPasswordResetLink" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" label="{{ __('Email Address') }}" type="email" name="email" required autofocus placeholder="email@example.com" />
</div>
<flux:button variant="primary" type="submit" class="w-full">{{ __('Email password reset link') }}</flux:button>
</form>
<div class="space-x-1 text-center text-sm text-zinc-400">
Or, return to
<x-text-link href="{{ route('login') }}">log in</x-text-link>
</div>
</div>

View file

@ -0,0 +1,152 @@
<?php
use Illuminate\Auth\Events\Lockout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
#[Validate('required|string|email')]
public string $email = '';
#[Validate('required|string')]
public string $password = '';
public bool $remember = true;
/**
* Handle an incoming authentication request.
*/
public function login(): void
{
$this->validate();
$this->ensureIsNotRateLimited();
if (!Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
Session::regenerate();
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
}
/**
* Ensure the authentication request is not rate limited.
*/
protected function ensureIsNotRateLimited(): void
{
if (!RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout(request()));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => __('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the authentication rate limiting throttle key.
*/
protected function throttleKey(): string
{
return Str::transliterate(Str::lower($this->email) . '|' . request()->ip());
}
public function mount(): void
{
if (app()->isLocal()) {
$this->email = 'admin@example.com';
$this->password = 'admin@example.com';
}
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Log in to your account" description="Enter your email and password below to log in"/>
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')"/>
<form wire:submit="login" class="flex flex-col gap-6">
<!-- Email Address -->
<flux:input wire:model="email" label="{{ __('Email address') }}" type="email" name="email" required autofocus
autocomplete="email" placeholder="admin@example.com"/>
<!-- Password -->
<div class="relative">
<flux:input
wire:model="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="current-password"
placeholder="Password"
/>
@if (Route::has('password.request'))
<x-text-link class="absolute right-0 top-0" href="{{ route('password.request') }}">
{{ __('Forgot your password?') }}
</x-text-link>
@endif
</div>
<!-- Remember Me -->
<flux:checkbox wire:model="remember" label="{{ __('Remember me') }}"/>
<div class="flex items-center justify-end">
<flux:button variant="primary" type="submit" class="w-full">{{ __('Log in') }}</flux:button>
</div>
</form>
@if (config('services.oidc.enabled'))
<div class="relative">
<div class="absolute inset-0 flex items-center">
<span class="w-full border-t border-zinc-300 dark:border-zinc-600"></span>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white dark:bg-zinc-900 px-2 text-zinc-500 dark:text-zinc-400">
{{ __('Or') }}
</span>
</div>
</div>
<div class="flex items-center justify-end">
<flux:button
variant="outline"
type="button"
class="w-full"
href="{{ route('auth.oidc.redirect') }}"
>
{{ __('Continue with OIDC') }}
</flux:button>
</div>
@endif
@if (Route::has('register'))
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Don't have an account?
<x-text-link href="{{ route('register') }}">Sign up</x-text-link>
</div>
@endif
</div>

View file

@ -0,0 +1,94 @@
<?php
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Handle an incoming registration request.
*/
public function register(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:' . User::class],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
$validated['password'] = Hash::make($validated['password']);
event(new Registered(($user = User::create($validated))));
Auth::login($user);
$this->redirect(route('dashboard', absolute: false), navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Create an account" description="Enter your details below to create your account" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="register" class="flex flex-col gap-6">
<!-- Name -->
<div class="grid gap-2">
<flux:input wire:model="name" id="name" label="{{ __('Name') }}" type="text" name="name" required autofocus autocomplete="name" placeholder="Full name" />
</div>
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email address') }}" type="email" name="email" required autocomplete="email" placeholder="email@example.com" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Create account') }}
</flux:button>
</div>
</form>
<div class="space-x-1 text-center text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?
<x-text-link href="{{ route('login') }}">Log in</x-text-link>
</div>
</div>

View file

@ -0,0 +1,117 @@
<?php
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
#[Locked]
public string $token = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
/**
* Mount the component.
*/
public function mount(string $token): void
{
$this->token = $token;
$this->email = request()->string('email');
}
/**
* Reset the password for the given user.
*/
public function resetPassword(): void
{
$this->validate([
'token' => ['required'],
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$this->only('email', 'password', 'password_confirmation', 'token'),
function ($user) {
$user->forceFill([
'password' => Hash::make($this->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
if ($status != Password::PasswordReset) {
$this->addError('email', __($status));
return;
}
Session::flash('status', __($status));
$this->redirectRoute('login', navigate: true);
}
}; ?>
<div class="flex flex-col gap-6">
<x-auth-header title="Reset password" description="Please enter your new password below" />
<!-- Session Status -->
<x-auth-session-status class="text-center" :status="session('status')" />
<form wire:submit="resetPassword" class="flex flex-col gap-6">
<!-- Email Address -->
<div class="grid gap-2">
<flux:input wire:model="email" id="email" label="{{ __('Email') }}" type="email" name="email" required autocomplete="email" />
</div>
<!-- Password -->
<div class="grid gap-2">
<flux:input
wire:model="password"
id="password"
label="{{ __('Password') }}"
type="password"
name="password"
required
autocomplete="new-password"
placeholder="Password"
/>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<flux:input
wire:model="password_confirmation"
id="password_confirmation"
label="{{ __('Confirm password') }}"
type="password"
name="password_confirmation"
required
autocomplete="new-password"
placeholder="Confirm password"
/>
</div>
<div class="flex items-center justify-end">
<flux:button type="submit" variant="primary" class="w-full">
{{ __('Reset password') }}
</flux:button>
</div>
</form>
</div>

View file

@ -0,0 +1,61 @@
<?php
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Livewire\Attributes\Layout;
use Livewire\Volt\Component;
new #[Layout('components.layouts.auth')] class extends Component {
/**
* Send an email verification notification to the user.
*/
public function sendVerification(): void
{
if (Auth::user()->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false), navigate: true);
return;
}
Auth::user()->sendEmailVerificationNotification();
Session::flash('status', 'verification-link-sent');
}
/**
* Log the current user out of the application.
*/
public function logout(Logout $logout): void
{
$logout();
$this->redirect('/', navigate: true);
}
}; ?>
<div class="mt-4 flex flex-col gap-6">
<div class="text-center text-sm text-gray-600">
{{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
</div>
@if (session('status') == 'verification-link-sent')
<div class="font-medium text-center text-sm text-green-600">
{{ __('A new verification link has been sent to the email address you provided during registration.') }}
</div>
@endif
<div class="flex flex-col items-center justify-between space-y-3">
<flux:button wire:click="sendVerification" variant="primary" class="w-full">
{{ __('Resend verification email') }}
</flux:button>
<button
wire:click="logout"
type="submit"
class="rounded-md text-sm text-gray-600 underline hover:text-gray-900 focus:outline-hidden focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{{ __('Log out') }}
</button>
</div>
</div>

View file

@ -1,52 +1,29 @@
<?php <?php
use App\Services\PluginImportService; use App\Services\PluginImportService;
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\Component;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
new new class extends Component {
#[Lazy]
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();
} }
public function placeholder()
{
return <<<'HTML'
<div class="space-y-4">
<div class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<flux:icon.loading />
<flux:text>Loading recipes...</flux:text>
</div>
</div>
</div>
HTML;
}
private function loadCatalogPlugins(): void private function loadCatalogPlugins(): void
{ {
$catalogUrl = config('app.catalog_url'); $catalogUrl = config('app.catalog_url');
$this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) { $this->catalogPlugins = Cache::remember('catalog_plugins', 43200, function () use ($catalogUrl) {
try { try {
$response = Http::timeout(10)->get($catalogUrl); $response = Http::get($catalogUrl);
$catalogContent = $response->body(); $catalogContent = $response->body();
$catalog = Yaml::parse($catalogContent); $catalog = Yaml::parse($catalogContent);
@ -55,7 +32,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;
} }
@ -81,14 +58,12 @@ class extends Component
'logo_url' => Arr::get($plugin, 'logo_url'), 'logo_url' => Arr::get($plugin, 'logo_url'),
'screenshot_url' => Arr::get($plugin, 'screenshot_url'), 'screenshot_url' => Arr::get($plugin, 'screenshot_url'),
'learn_more_url' => Arr::get($plugin, 'author_bio.learn_more_url'), 'learn_more_url' => Arr::get($plugin, 'author_bio.learn_more_url'),
'preferred_renderer' => Arr::get($plugin, 'byos.byos_laravel.renderer'),
]; ];
}) })
->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 [];
} }
}); });
@ -100,9 +75,8 @@ 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;
} }
@ -113,46 +87,25 @@ class extends Component
$plugin['zip_url'], $plugin['zip_url'],
auth()->user(), auth()->user(),
$plugin['zip_entry_path'] ?? null, $plugin['zip_entry_path'] ?? null,
config('services.trmnl.liquid_enabled') ? $plugin['preferred_renderer'] : 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-zinc-400" /> <flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-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>
@ -163,30 +116,30 @@ class extends Component
@enderror @enderror
@foreach($catalogPlugins as $plugin) @foreach($catalogPlugins as $plugin)
<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="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'] }}" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
@else @else
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center"> <div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" /> <flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-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>
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading> <h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
@if ($plugin['github']) @if ($plugin['github'])
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text> <p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
@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="zinc" size="sm">{{ $plugin['license'] }}</flux:badge> <flux:badge color="gray" 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-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"> <a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<flux:icon name="github" class="w-5 h-5" /> <flux:icon name="github" class="w-5 h-5" />
</a> </a>
@endif @endif
@ -194,7 +147,7 @@ class extends Component
</div> </div>
@if($plugin['description']) @if($plugin['description'])
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text> <p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
@endif @endif
<div class="mt-4 flex items-center space-x-3"> <div class="mt-4 flex items-center space-x-3">
@ -204,19 +157,6 @@ 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'] }}"
@ -232,38 +172,4 @@ 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>

Some files were not shown because too many files have changed in this diff Show more