Merge branch 'usetrmnl:main' into main
|
|
@ -9,7 +9,11 @@ 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
|
||||||
|
|
@ -18,8 +22,10 @@ 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
|
RUN docker-php-ext-install imagick zip gd
|
||||||
|
|
||||||
# Composer uses its php binary, but we want it to use the container's one
|
# Composer uses its php binary, but we want it to use the container's one
|
||||||
RUN rm -f /usr/bin/php84
|
RUN rm -f /usr/bin/php84
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,12 @@ 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
|
||||||
|
|
@ -23,8 +28,10 @@ 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
|
RUN docker-php-ext-install imagick zip gd
|
||||||
|
|
||||||
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
|
||||||
|
|
|
||||||
2
.github/FUNDING.yml
vendored
|
|
@ -1 +1 @@
|
||||||
custom: ["https://usetrmnl.com/?ref=laravel-trmnl"]
|
custom: ["https://trmnl.com/?ref=laravel-trmnl"]
|
||||||
|
|
|
||||||
13
.gitignore
vendored
|
|
@ -29,3 +29,16 @@ 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
|
||||||
|
/.cursor
|
||||||
|
/.opencode
|
||||||
|
/build.sh
|
||||||
|
/.junie
|
||||||
|
/.agents
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ 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/
|
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.2.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
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||||
|
|
||||||
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||||
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 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 30k downloads and 130+ stars, it’s the most popular community-driven BYOS.
|
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ stars, it’s the most popular community-driven BYOS.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|
@ -15,9 +15,9 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
|
||||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||||
* 🔍 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://trmnl.com/framework)
|
||||||
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
||||||
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
|
* Import from the [TRMNL community recipe catalog](https://trmnl.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
|
||||||
|
|
@ -43,7 +43,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
|
||||||
### Support ❤️
|
### Support ❤️
|
||||||
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
||||||
|
|
||||||
Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
Support the development of this package by purchasing a TRMNL device through the referral link: https://trmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||||
|
|
||||||
or
|
or
|
||||||
|
|
||||||
|
|
@ -122,6 +122,7 @@ php artisan db:seed --class=ExampleRecipesSeeder
|
||||||
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
||||||
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
|
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
|
||||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
||||||
|
| `TRUSTED_PROXIES` | If your server handles SSL termination, allow mixed mode. e.g. `"172.0.0.0/8"` or `*` | null |
|
||||||
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
|
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
|
||||||
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
|
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
|
||||||
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
|
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
|
||||||
|
|
|
||||||
33
app/Actions/Fortify/CreateNewUser.php
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Concerns/PasswordValidationRules.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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'];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Concerns/ProfileValidationRules.php
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Facades/QrCode.php
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
216
app/Jobs/CheckVersionUpdateJob.php
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Settings\UpdateSettings;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class CheckVersionUpdateJob
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
private const CACHE_KEY = 'latest_release';
|
||||||
|
|
||||||
|
private const BACKOFF_KEY = 'github_api_backoff';
|
||||||
|
|
||||||
|
private const BACKOFF_MINUTES = 10;
|
||||||
|
|
||||||
|
private const CACHE_TTL = 86400;
|
||||||
|
|
||||||
|
public function __construct(private bool $forceRefresh = false) {}
|
||||||
|
|
||||||
|
public function handle(UpdateSettings $updateSettings): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$currentVersion = config('app.version');
|
||||||
|
|
||||||
|
if (! $currentVersion) {
|
||||||
|
return $this->errorResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$backoffUntil = Cache::get(self::BACKOFF_KEY);
|
||||||
|
if ($this->isInBackoffPeriod($backoffUntil)) {
|
||||||
|
return $this->rateLimitResponse($backoffUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cachedResponse = Cache::get(self::CACHE_KEY);
|
||||||
|
$response = $this->fetchOrUseCache($cachedResponse, $updateSettings->prereleases, $backoffUntil);
|
||||||
|
|
||||||
|
if (! $response) {
|
||||||
|
return $this->errorResponse('fetch_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$latestVersion, $releaseData] = $this->extractLatestVersion($response, $updateSettings->prereleases);
|
||||||
|
$isNewer = $latestVersion && version_compare($latestVersion, $currentVersion, '>');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'latest_version' => $latestVersion,
|
||||||
|
'is_newer' => $isNewer,
|
||||||
|
'release_data' => $releaseData,
|
||||||
|
'error' => null,
|
||||||
|
];
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::error('Version check failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return $this->errorResponse('connection_failed');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error('Unexpected error in version check: '.$e->getMessage());
|
||||||
|
|
||||||
|
return $this->errorResponse('unexpected_error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isInBackoffPeriod(?\Illuminate\Support\Carbon $backoffUntil): bool
|
||||||
|
{
|
||||||
|
return $backoffUntil !== null && now()->isBefore($backoffUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rateLimitResponse(\Illuminate\Support\Carbon $backoffUntil): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'latest_version' => null,
|
||||||
|
'is_newer' => false,
|
||||||
|
'release_data' => null,
|
||||||
|
'error' => 'rate_limit',
|
||||||
|
'backoff_until' => $backoffUntil->timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function errorResponse(?string $error = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'latest_version' => null,
|
||||||
|
'is_newer' => false,
|
||||||
|
'release_data' => null,
|
||||||
|
'error' => $error,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchOrUseCache(?array $cachedResponse, bool $enablePrereleases, ?\Illuminate\Support\Carbon $backoffUntil): ?array
|
||||||
|
{
|
||||||
|
if ($cachedResponse && ! $this->forceRefresh) {
|
||||||
|
return $cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isInBackoffPeriod($backoffUntil)) {
|
||||||
|
return $cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$httpResponse = $this->fetchReleases($enablePrereleases);
|
||||||
|
|
||||||
|
if ($httpResponse->status() === 429) {
|
||||||
|
return $this->handleRateLimit($cachedResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpResponse->successful()) {
|
||||||
|
$responseData = $httpResponse->json();
|
||||||
|
Cache::put(self::CACHE_KEY, $responseData, self::CACHE_TTL);
|
||||||
|
|
||||||
|
return $responseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::warning('GitHub API request failed', [
|
||||||
|
'status' => $httpResponse->status(),
|
||||||
|
'body' => $httpResponse->body(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $cachedResponse;
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
Log::debug('Failed to fetch releases: '.$e->getMessage());
|
||||||
|
|
||||||
|
return $cachedResponse ?? null;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::debug('Failed to fetch releases: '.$e->getMessage());
|
||||||
|
|
||||||
|
return $cachedResponse ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchReleases(bool $enablePrereleases)
|
||||||
|
{
|
||||||
|
$githubRepo = config('app.github_repo');
|
||||||
|
$apiBaseUrl = "https://api.github.com/repos/{$githubRepo}";
|
||||||
|
$endpoint = $enablePrereleases ? "{$apiBaseUrl}/releases" : "{$apiBaseUrl}/releases/latest";
|
||||||
|
|
||||||
|
return Http::timeout(10)->connectTimeout(5)->get($endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleRateLimit(?array $cachedResponse): ?array
|
||||||
|
{
|
||||||
|
$backoffUntil = now()->addMinutes(self::BACKOFF_MINUTES);
|
||||||
|
Cache::put(self::BACKOFF_KEY, $backoffUntil, 600);
|
||||||
|
Log::warning('GitHub API rate limit exceeded. Backing off for 10 minutes.');
|
||||||
|
|
||||||
|
return $cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractLatestVersion(array $response, bool $enablePrereleases): array
|
||||||
|
{
|
||||||
|
if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) {
|
||||||
|
return [
|
||||||
|
Arr::get($response, 'tag_name'),
|
||||||
|
$response,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
[$stableRelease, $prerelease] = $this->findReleases($response);
|
||||||
|
|
||||||
|
if ($prerelease && $stableRelease) {
|
||||||
|
$prereleaseVersion = Arr::get($prerelease, 'tag_name');
|
||||||
|
$stableVersion = Arr::get($stableRelease, 'tag_name');
|
||||||
|
|
||||||
|
if (version_compare($prereleaseVersion, $stableVersion, '>')) {
|
||||||
|
return [$prereleaseVersion, $prerelease];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$stableVersion, $stableRelease];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prerelease) {
|
||||||
|
return [Arr::get($prerelease, 'tag_name'), $prerelease];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stableRelease) {
|
||||||
|
return [Arr::get($stableRelease, 'tag_name'), $stableRelease];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findReleases(array $allReleases): array
|
||||||
|
{
|
||||||
|
$stableRelease = null;
|
||||||
|
$prerelease = null;
|
||||||
|
|
||||||
|
foreach ($allReleases as $release) {
|
||||||
|
$tagName = Arr::get($release, 'tag_name');
|
||||||
|
if (! $tagName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isPrerelease = (bool) Arr::get($release, 'prerelease', false);
|
||||||
|
|
||||||
|
if ($isPrerelease && ! $prerelease) {
|
||||||
|
$prerelease = $release;
|
||||||
|
} elseif (! $isPrerelease && ! $stableRelease) {
|
||||||
|
$stableRelease = $release;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stableRelease && $prerelease) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$stableRelease, $prerelease];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
private const API_URL = 'https://usetrmnl.com/api/models';
|
private const API_URL = '/api/models';
|
||||||
|
|
||||||
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ final class FetchDeviceModelsJob implements ShouldQueue
|
||||||
try {
|
try {
|
||||||
$this->processPalettes();
|
$this->processPalettes();
|
||||||
|
|
||||||
$response = Http::timeout(30)->get(self::API_URL);
|
$response = Http::timeout(30)->get(config('services.trmnl.base_url').self::API_URL);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('Failed to fetch device models from API', [
|
Log::error('Failed to fetch device models from API', [
|
||||||
|
|
@ -199,6 +199,7 @@ 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',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ use Exception;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
@ -18,100 +19,126 @@ class FetchProxyCloudResponses implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
Device::where('proxy_cloud', true)->each(function ($device): void {
|
Device::where('proxy_cloud', true)->each(function ($device): void {
|
||||||
if (! $device->getNextPlaylistItem()) {
|
if ($device->getNextPlaylistItem()) {
|
||||||
try {
|
|
||||||
$response = Http::withHeaders([
|
|
||||||
'id' => $device->mac_address,
|
|
||||||
'access-token' => $device->api_key,
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rssi' => $device->last_rssi_level,
|
|
||||||
'battery_voltage' => $device->last_battery_voltage,
|
|
||||||
'refresh-rate' => $device->default_refresh_interval,
|
|
||||||
'fw-version' => $device->last_firmware_version,
|
|
||||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
|
||||||
'user-agent' => 'ESP32HTTPClient',
|
|
||||||
])->get(config('services.trmnl.proxy_base_url').'/api/display');
|
|
||||||
|
|
||||||
$device->update([
|
|
||||||
'proxy_cloud_response' => $response->json(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$imageUrl = $response->json('image_url');
|
|
||||||
$filename = $response->json('filename');
|
|
||||||
|
|
||||||
parse_str(parse_url($imageUrl)['query'] ?? '', $queryParams);
|
|
||||||
$imageType = urldecode($queryParams['response-content-type'] ?? 'image/bmp');
|
|
||||||
$imageExtension = $imageType === 'image/png' ? 'png' : 'bmp';
|
|
||||||
|
|
||||||
if (Str::contains($imageUrl, '.png')) {
|
|
||||||
$imageExtension = 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
\Log::info("Response data: $imageUrl. Image Extension: $imageExtension");
|
|
||||||
if (isset($imageUrl)) {
|
|
||||||
try {
|
|
||||||
$imageContents = Http::get($imageUrl)->body();
|
|
||||||
if (! Storage::disk('public')->exists("images/generated/{$filename}.{$imageExtension}")) {
|
|
||||||
Storage::disk('public')->put(
|
|
||||||
"images/generated/{$filename}.{$imageExtension}",
|
|
||||||
$imageContents
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$device->update([
|
|
||||||
'current_screen_image' => $filename,
|
|
||||||
]);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
|
||||||
|
|
||||||
if ($device->last_log_request) {
|
|
||||||
try {
|
|
||||||
Http::withHeaders([
|
|
||||||
'id' => $device->mac_address,
|
|
||||||
'access-token' => $device->api_key,
|
|
||||||
'width' => 800,
|
|
||||||
'height' => 480,
|
|
||||||
'rssi' => $device->last_rssi_level,
|
|
||||||
'battery_voltage' => $device->last_battery_voltage,
|
|
||||||
'refresh-rate' => $device->default_refresh_interval,
|
|
||||||
'fw-version' => $device->last_firmware_version,
|
|
||||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
|
||||||
'user-agent' => 'ESP32HTTPClient',
|
|
||||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
|
||||||
|
|
||||||
// Only clear the pending log request if the POST succeeded
|
|
||||||
$device->update([
|
|
||||||
'last_log_request' => null,
|
|
||||||
]);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Do not fail the entire proxy fetch if the log upload fails
|
|
||||||
Log::error("Failed to upload device log for device: {$device->mac_address}", [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log::info("Skipping device: {$device->mac_address} as it has a pending playlist item.");
|
Log::info("Skipping device: {$device->mac_address} as it has a pending playlist item.");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->fetchDisplayResponse($device);
|
||||||
|
$device->update([
|
||||||
|
'proxy_cloud_response' => $response->json(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->processImage($device, $response);
|
||||||
|
$this->uploadLogRequest($device);
|
||||||
|
|
||||||
|
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function fetchDisplayResponse(Device $device): Response
|
||||||
|
{
|
||||||
|
/** @var Response $response */
|
||||||
|
$response = Http::withHeaders($this->getDeviceHeaders($device))
|
||||||
|
->get(config('services.trmnl.proxy_base_url').'/api/display');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDeviceHeaders(Device $device): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $device->mac_address,
|
||||||
|
'access-token' => $device->api_key,
|
||||||
|
'width' => 800,
|
||||||
|
'height' => 480,
|
||||||
|
'rssi' => $device->last_rssi_level,
|
||||||
|
'battery_voltage' => $device->last_battery_voltage,
|
||||||
|
'refresh-rate' => $device->default_refresh_interval,
|
||||||
|
'fw-version' => $device->last_firmware_version,
|
||||||
|
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||||
|
'user-agent' => 'ESP32HTTPClient',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processImage(Device $device, Response $response): void
|
||||||
|
{
|
||||||
|
$imageUrl = $response->json('image_url');
|
||||||
|
$filename = $response->json('filename');
|
||||||
|
|
||||||
|
if ($imageUrl === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$imageExtension = $this->determineImageExtension($imageUrl);
|
||||||
|
Log::info("Response data: $imageUrl. Image Extension: $imageExtension");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$imageContents = Http::get($imageUrl)->body();
|
||||||
|
$filePath = "images/generated/{$filename}.{$imageExtension}";
|
||||||
|
|
||||||
|
if (! Storage::disk('public')->exists($filePath)) {
|
||||||
|
Storage::disk('public')->put($filePath, $imageContents);
|
||||||
|
}
|
||||||
|
|
||||||
|
$device->update([
|
||||||
|
'current_screen_image' => $filename,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function determineImageExtension(?string $imageUrl): string
|
||||||
|
{
|
||||||
|
if ($imageUrl === null) {
|
||||||
|
return 'bmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Str::contains($imageUrl, '.png')) {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsedUrl = parse_url($imageUrl);
|
||||||
|
if ($parsedUrl === false || ! isset($parsedUrl['query'])) {
|
||||||
|
return 'bmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
parse_str($parsedUrl['query'], $queryParams);
|
||||||
|
$imageType = urldecode($queryParams['response-content-type'] ?? 'image/bmp');
|
||||||
|
|
||||||
|
return $imageType === 'image/png' ? 'png' : 'bmp';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function uploadLogRequest(Device $device): void
|
||||||
|
{
|
||||||
|
if (! $device->last_log_request) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Http::withHeaders($this->getDeviceHeaders($device))
|
||||||
|
->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||||
|
|
||||||
|
$device->update([
|
||||||
|
'last_log_request' => null,
|
||||||
|
]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::error("Failed to upload device log for device: {$device->mac_address}", [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ class FirmwarePollJob implements ShouldQueue
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json();
|
$firmwareEndpoint = config('services.trmnl.base_url').'/api/firmware/latest';
|
||||||
|
|
||||||
|
$response = Http::get($firmwareEndpoint)->json();
|
||||||
|
|
||||||
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
||||||
Log::error('Invalid firmware response format received');
|
Log::error('Invalid firmware response format received');
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
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;
|
||||||
|
|
@ -58,4 +59,31 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
app/Liquid/Tags/PluginRenderTag.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Liquid\Tags;
|
||||||
|
|
||||||
|
use Keepsuit\Liquid\Render\RenderContext;
|
||||||
|
use Keepsuit\Liquid\Support\MissingValue;
|
||||||
|
use Keepsuit\Liquid\Tags\RenderTag;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render tag that injects plugin context (trmnl, size, data, config) into partials
|
||||||
|
* so shared templates can use variables like trmnl.user.name without passing them explicitly.
|
||||||
|
*/
|
||||||
|
class PluginRenderTag extends RenderTag
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Root-level keys from the plugin render context that should be available in partials.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const PARENT_CONTEXT_KEYS = ['trmnl', 'size', 'data', 'config'];
|
||||||
|
|
||||||
|
protected function buildPartialContext(RenderContext $rootContext, string $templateName, array $variables = []): RenderContext
|
||||||
|
{
|
||||||
|
$partialContext = $rootContext->newIsolatedSubContext($templateName);
|
||||||
|
|
||||||
|
foreach (self::PARENT_CONTEXT_KEYS as $key) {
|
||||||
|
$value = $rootContext->get($key);
|
||||||
|
if ($value !== null && ! $value instanceof MissingValue) {
|
||||||
|
$partialContext->set($key, $value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($variables as $key => $value) {
|
||||||
|
$partialContext->set($key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->attributes as $key => $value) {
|
||||||
|
$partialContext->set($key, $rootContext->evaluate($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $partialContext;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ class DeviceAutoJoin extends Component
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
$this->deviceAutojoin = auth()->user()->assign_new_devices;
|
$this->deviceAutojoin = (bool) (auth()->user()->assign_new_devices ?? false);
|
||||||
$this->isFirstUser = auth()->user()->id === 1;
|
$this->isFirstUser = auth()->user()->id === 1;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,14 @@ class Device extends Model
|
||||||
|
|
||||||
protected $guarded = ['id'];
|
protected $guarded = ['id'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the MAC address attribute, normalizing to uppercase.
|
||||||
|
*/
|
||||||
|
public function setMacAddressAttribute(?string $value): void
|
||||||
|
{
|
||||||
|
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
|
||||||
|
}
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'battery_notification_sent' => 'boolean',
|
'battery_notification_sent' => 'boolean',
|
||||||
'proxy_cloud' => 'boolean',
|
'proxy_cloud' => 'boolean',
|
||||||
|
|
@ -34,6 +42,7 @@ class Device extends Model
|
||||||
'sleep_mode_to' => 'datetime:H:i',
|
'sleep_mode_to' => 'datetime:H:i',
|
||||||
'special_function' => 'string',
|
'special_function' => 'string',
|
||||||
'pause_until' => 'datetime',
|
'pause_until' => 'datetime',
|
||||||
|
'maximum_compatibility' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function getBatteryPercentAttribute(): int|float
|
public function getBatteryPercentAttribute(): int|float
|
||||||
|
|
|
||||||
|
|
@ -37,21 +37,32 @@ class Playlist extends Model
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check weekday
|
// Get user's timezone or fall back to app timezone
|
||||||
if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) {
|
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
||||||
|
$now = now($timezone);
|
||||||
|
|
||||||
|
// Check weekday (using timezone-aware time)
|
||||||
|
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->active_from !== null && $this->active_until !== null) {
|
if ($this->active_from !== null && $this->active_until !== null) {
|
||||||
$now = now();
|
// Create timezone-aware datetime objects for active_from and active_until
|
||||||
|
$activeFrom = $now->copy()
|
||||||
|
->setTimeFrom($this->active_from)
|
||||||
|
->timezone($timezone);
|
||||||
|
|
||||||
|
$activeUntil = $now->copy()
|
||||||
|
->setTimeFrom($this->active_until)
|
||||||
|
->timezone($timezone);
|
||||||
|
|
||||||
// Handle time ranges that span across midnight
|
// Handle time ranges that span across midnight
|
||||||
if ($this->active_from > $this->active_until) {
|
if ($activeFrom > $activeUntil) {
|
||||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||||
if ($now >= $this->active_from || $now <= $this->active_until) {
|
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} elseif ($now >= $this->active_from && $now <= $this->active_until) {
|
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ class PlaylistItem extends Model
|
||||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'slot' => $this->plugin instanceof Plugin
|
'slot' => $this->plugin instanceof Plugin
|
||||||
? $this->plugin->render('full', false)
|
? $this->plugin->render('full', false, $device)
|
||||||
: throw new Exception('Invalid plugin instance'),
|
: throw new Exception('Invalid plugin instance'),
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
@ -157,7 +157,7 @@ class PlaylistItem extends Model
|
||||||
|
|
||||||
foreach ($plugins as $index => $plugin) {
|
foreach ($plugins as $index => $plugin) {
|
||||||
$size = $this->getLayoutSize($index);
|
$size = $this->getLayoutSize($index);
|
||||||
$pluginMarkups[] = $plugin->render($size, false);
|
$pluginMarkups[] = $plugin->render($size, false, $device);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('trmnl-layouts.mashup', [
|
return view('trmnl-layouts.mashup', [
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use App\Liquid\Filters\Numbers;
|
||||||
use App\Liquid\Filters\StandardFilters;
|
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\PluginRenderTag;
|
||||||
use App\Liquid\Tags\TemplateTag;
|
use App\Liquid\Tags\TemplateTag;
|
||||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
|
|
@ -24,9 +25,11 @@ 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 Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
class Plugin extends Model
|
class Plugin extends Model
|
||||||
{
|
{
|
||||||
|
|
@ -44,6 +47,8 @@ class Plugin extends Model
|
||||||
'no_bleed' => 'boolean',
|
'no_bleed' => 'boolean',
|
||||||
'dark_mode' => 'boolean',
|
'dark_mode' => 'boolean',
|
||||||
'preferred_renderer' => 'string',
|
'preferred_renderer' => 'string',
|
||||||
|
'plugin_type' => 'string',
|
||||||
|
'alias' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected static function boot()
|
protected static function boot()
|
||||||
|
|
@ -55,13 +60,107 @@ class Plugin extends Model
|
||||||
$model->uuid = Str::uuid();
|
$model->uuid = Str::uuid();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static::updating(function ($model): void {
|
||||||
|
// Reset image cache when any markup changes
|
||||||
|
if ($model->isDirty([
|
||||||
|
'render_markup',
|
||||||
|
'render_markup_half_horizontal',
|
||||||
|
'render_markup_half_vertical',
|
||||||
|
'render_markup_quadrant',
|
||||||
|
'render_markup_shared',
|
||||||
|
])) {
|
||||||
|
$model->current_image = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanitize configuration template on save
|
||||||
|
static::saving(function ($model): void {
|
||||||
|
$model->sanitizeTemplate();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const CUSTOM_FIELDS_KEY = 'custom_fields';
|
||||||
|
|
||||||
public function user()
|
public function user()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
return $this->belongsTo(User::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YAML for the custom_fields editor
|
||||||
|
*/
|
||||||
|
public function getCustomFieldsEditorYaml(): string
|
||||||
|
{
|
||||||
|
$template = $this->configuration_template;
|
||||||
|
$list = $template[self::CUSTOM_FIELDS_KEY] ?? null;
|
||||||
|
if ($list === null || $list === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Yaml::dump($list, 4, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse editor YAML and return configuration_template for DB (custom_fields key). Returns null when empty.
|
||||||
|
*/
|
||||||
|
public static function configurationTemplateFromCustomFieldsYaml(string $yaml, ?array $existingTemplate): ?array
|
||||||
|
{
|
||||||
|
$list = $yaml !== '' ? Yaml::parse($yaml) : [];
|
||||||
|
if ($list === null || (is_array($list) && $list === [])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$template = $existingTemplate ?? [];
|
||||||
|
$template[self::CUSTOM_FIELDS_KEY] = is_array($list) ? $list : [];
|
||||||
|
|
||||||
|
return $template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that each custom field entry has field_type and name. For use with parsed editor YAML.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $list
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public static function validateCustomFieldsList(array $list): void
|
||||||
|
{
|
||||||
|
$validator = \Illuminate\Support\Facades\Validator::make(
|
||||||
|
['custom_fields' => $list],
|
||||||
|
[
|
||||||
|
'custom_fields' => ['required', 'array'],
|
||||||
|
'custom_fields.*.field_type' => ['required', 'string'],
|
||||||
|
'custom_fields.*.name' => ['required', 'string'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'custom_fields.*.field_type.required' => 'Each custom field must have a field_type.',
|
||||||
|
'custom_fields.*.name.required' => 'Each custom field must have a name.',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$validator->validate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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'])) {
|
||||||
|
|
@ -102,6 +201,11 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function isDataStale(): bool
|
public function isDataStale(): bool
|
||||||
{
|
{
|
||||||
|
// Image webhook plugins don't use data staleness - images are pushed directly
|
||||||
|
if ($this->plugin_type === 'image_webhook') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->data_strategy === 'webhook') {
|
if ($this->data_strategy === 'webhook') {
|
||||||
// Treat as stale if any webhook event has occurred in the past hour
|
// Treat as stale if any webhook event has occurred in the past hour
|
||||||
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||||
|
|
@ -115,105 +219,71 @@ class Plugin extends Model
|
||||||
|
|
||||||
public function updateDataPayload(): void
|
public function updateDataPayload(): void
|
||||||
{
|
{
|
||||||
if ($this->data_strategy === 'polling' && $this->polling_url) {
|
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||||
|
return;
|
||||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
|
||||||
|
|
||||||
if ($this->polling_header) {
|
|
||||||
// Resolve Liquid variables in the polling header
|
|
||||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
|
||||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
|
||||||
foreach ($headerLines as $line) {
|
|
||||||
$parts = explode(':', $line, 2);
|
|
||||||
if (count($parts) === 2) {
|
|
||||||
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve Liquid variables in the entire polling_url field first, then split by newline
|
|
||||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
|
||||||
$urls = array_filter(
|
|
||||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
|
||||||
fn ($url): bool => ! empty($url)
|
|
||||||
);
|
|
||||||
|
|
||||||
// If only one URL, use the original logic without nesting
|
|
||||||
if (count($urls) === 1) {
|
|
||||||
$url = reset($urls);
|
|
||||||
$httpRequest = Http::withHeaders($headers);
|
|
||||||
|
|
||||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
|
||||||
// Resolve Liquid variables in the polling body
|
|
||||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
|
||||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL is already resolved, use it directly
|
|
||||||
$resolvedUrl = $url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Make the request based on the verb
|
|
||||||
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
|
||||||
|
|
||||||
$response = $this->parseResponse($httpResponse);
|
|
||||||
|
|
||||||
$this->update([
|
|
||||||
'data_payload' => $response,
|
|
||||||
'data_payload_updated_at' => now(),
|
|
||||||
]);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
|
||||||
$this->update([
|
|
||||||
'data_payload' => ['error' => 'Failed to fetch data'],
|
|
||||||
'data_payload_updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple URLs - use nested response logic
|
|
||||||
$combinedResponse = [];
|
|
||||||
|
|
||||||
foreach ($urls as $index => $url) {
|
|
||||||
$httpRequest = Http::withHeaders($headers);
|
|
||||||
|
|
||||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
|
||||||
// Resolve Liquid variables in the polling body
|
|
||||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
|
||||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL is already resolved, use it directly
|
|
||||||
$resolvedUrl = $url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Make the request based on the verb
|
|
||||||
$httpResponse = $this->polling_verb === 'post' ? $httpRequest->post($resolvedUrl) : $httpRequest->get($resolvedUrl);
|
|
||||||
|
|
||||||
$response = $this->parseResponse($httpResponse);
|
|
||||||
|
|
||||||
// Check if response is an array at root level
|
|
||||||
if (array_keys($response) === range(0, count($response) - 1)) {
|
|
||||||
// Response is a sequential array, nest under .data
|
|
||||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
|
||||||
} else {
|
|
||||||
// Response is an object or associative array, keep as is
|
|
||||||
$combinedResponse["IDX_{$index}"] = $response;
|
|
||||||
}
|
|
||||||
} catch (Exception $e) {
|
|
||||||
// Log error and continue with other URLs
|
|
||||||
Log::warning("Failed to fetch data from URL {$resolvedUrl}: ".$e->getMessage());
|
|
||||||
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->update([
|
|
||||||
'data_payload' => $combinedResponse,
|
|
||||||
'data_payload_updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||||
|
|
||||||
|
// resolve headers
|
||||||
|
if ($this->polling_header) {
|
||||||
|
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||||
|
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||||
|
foreach ($headerLines as $line) {
|
||||||
|
$parts = explode(':', $line, 2);
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve and clean URLs
|
||||||
|
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
||||||
|
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
||||||
|
array_map(trim(...), explode("\n", $resolvedPollingUrls)),
|
||||||
|
filled(...)
|
||||||
|
));
|
||||||
|
|
||||||
|
$combinedResponse = [];
|
||||||
|
|
||||||
|
// Loop through all URLs (Handles 1 or many)
|
||||||
|
foreach ($urls as $index => $url) {
|
||||||
|
$httpRequest = Http::withHeaders($headers);
|
||||||
|
|
||||||
|
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||||
|
$contentType = (array_key_exists('Content-Type', $headers))
|
||||||
|
? $headers['Content-Type']
|
||||||
|
: 'application/json';
|
||||||
|
|
||||||
|
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||||
|
$httpRequest = $httpRequest->withBody($resolvedBody, $contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$httpResponse = ($this->polling_verb === 'post')
|
||||||
|
? $httpRequest->post($url)
|
||||||
|
: $httpRequest->get($url);
|
||||||
|
|
||||||
|
$response = $this->parseResponse($httpResponse);
|
||||||
|
|
||||||
|
// Nest if it's a sequential array
|
||||||
|
if (array_keys($response) === range(0, count($response) - 1)) {
|
||||||
|
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||||
|
} else {
|
||||||
|
$combinedResponse["IDX_{$index}"] = $response;
|
||||||
|
}
|
||||||
|
} catch (Exception $e) {
|
||||||
|
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
|
||||||
|
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrap IDX_0 if only one URL
|
||||||
|
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'data_payload' => $finalPayload,
|
||||||
|
'data_payload_updated_at' => now(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseResponse(Response $httpResponse): array
|
private function parseResponse(Response $httpResponse): array
|
||||||
|
|
@ -416,7 +486,13 @@ 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->render_markup) {
|
if ($this->plugin_type !== 'recipe') {
|
||||||
|
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$markup = $this->getMarkupForSize($size);
|
||||||
|
|
||||||
|
if ($markup) {
|
||||||
$renderedContent = '';
|
$renderedContent = '';
|
||||||
|
|
||||||
if ($this->markup_language === 'liquid') {
|
if ($this->markup_language === 'liquid') {
|
||||||
|
|
@ -442,6 +518,13 @@ class Plugin extends Model
|
||||||
'locale' => 'en',
|
'locale' => 'en',
|
||||||
'time_zone_iana' => $timezone,
|
'time_zone_iana' => $timezone,
|
||||||
],
|
],
|
||||||
|
'device' => [
|
||||||
|
'friendly_id' => $device?->friendly_id,
|
||||||
|
'percent_charged' => $device?->battery_percent,
|
||||||
|
'wifi_strength' => $device?->wifi_strength,
|
||||||
|
'height' => $device?->height,
|
||||||
|
'width' => $device?->width,
|
||||||
|
],
|
||||||
'plugin_settings' => [
|
'plugin_settings' => [
|
||||||
'instance_name' => $this->name,
|
'instance_name' => $this->name,
|
||||||
'strategy' => $this->data_strategy,
|
'strategy' => $this->data_strategy,
|
||||||
|
|
@ -459,7 +542,7 @@ class Plugin extends Model
|
||||||
// Check if external renderer should be used
|
// Check if external renderer should be used
|
||||||
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
||||||
// Use external Ruby renderer - pass raw template without preprocessing
|
// Use external Ruby renderer - pass raw template without preprocessing
|
||||||
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
$renderedContent = $this->renderWithExternalLiquidRenderer($markup, $context);
|
||||||
} else {
|
} else {
|
||||||
// Use PHP keepsuit/liquid renderer
|
// Use PHP keepsuit/liquid renderer
|
||||||
// Create a custom environment with inline templates support
|
// Create a custom environment with inline templates support
|
||||||
|
|
@ -479,16 +562,18 @@ class Plugin extends Model
|
||||||
|
|
||||||
// Register the template tag for inline templates
|
// Register the template tag for inline templates
|
||||||
$environment->tagRegistry->register(TemplateTag::class);
|
$environment->tagRegistry->register(TemplateTag::class);
|
||||||
|
// Use plugin render tag so partials receive trmnl, size, data, config
|
||||||
|
$environment->tagRegistry->register(PluginRenderTag::class);
|
||||||
|
|
||||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||||
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
$processedMarkup = $this->applyLiquidReplacements($markup);
|
||||||
|
|
||||||
$template = $environment->parseString($processedMarkup);
|
$template = $environment->parseString($processedMarkup);
|
||||||
$liquidContext = $environment->newRenderContext(data: $context);
|
$liquidContext = $environment->newRenderContext(data: $context);
|
||||||
$renderedContent = $template->render($liquidContext);
|
$renderedContent = $template->render($liquidContext);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$renderedContent = Blade::render($this->render_markup, [
|
$renderedContent = Blade::render($markup, [
|
||||||
'size' => $size,
|
'size' => $size,
|
||||||
'data' => $this->data_payload,
|
'data' => $this->data_payload,
|
||||||
'config' => $this->configuration ?? [],
|
'config' => $this->configuration ?? [],
|
||||||
|
|
@ -523,17 +608,30 @@ class Plugin extends Model
|
||||||
|
|
||||||
if ($this->render_markup_view) {
|
if ($this->render_markup_view) {
|
||||||
if ($standalone) {
|
if ($standalone) {
|
||||||
return view('trmnl-layouts.single', [
|
$renderedView = view($this->render_markup_view, [
|
||||||
|
'size' => $size,
|
||||||
|
'data' => $this->data_payload,
|
||||||
|
'config' => $this->configuration ?? [],
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
if ($size === 'full') {
|
||||||
|
return view('trmnl-layouts.single', [
|
||||||
|
'colorDepth' => $device?->colorDepth(),
|
||||||
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
|
'noBleed' => $this->no_bleed,
|
||||||
|
'darkMode' => $this->dark_mode,
|
||||||
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
|
'slot' => $renderedView,
|
||||||
|
])->render();
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('trmnl-layouts.mashup', [
|
||||||
|
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||||
'colorDepth' => $device?->colorDepth(),
|
'colorDepth' => $device?->colorDepth(),
|
||||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||||
'noBleed' => $this->no_bleed,
|
|
||||||
'darkMode' => $this->dark_mode,
|
'darkMode' => $this->dark_mode,
|
||||||
'scaleLevel' => $device?->scaleLevel(),
|
'scaleLevel' => $device?->scaleLevel(),
|
||||||
'slot' => view($this->render_markup_view, [
|
'slot' => $renderedView,
|
||||||
'size' => $size,
|
|
||||||
'data' => $this->data_payload,
|
|
||||||
'config' => $this->configuration ?? [],
|
|
||||||
])->render(),
|
|
||||||
])->render();
|
])->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -556,6 +654,30 @@ class Plugin extends Model
|
||||||
return $this->configuration[$key] ?? $default;
|
return $this->configuration[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate markup for a given size, including shared prepending logic
|
||||||
|
*
|
||||||
|
* @param string $size The layout size (full, half_horizontal, half_vertical, quadrant)
|
||||||
|
* @return string|null The markup code for the given size, with shared prepended if available
|
||||||
|
*/
|
||||||
|
public function getMarkupForSize(string $size): ?string
|
||||||
|
{
|
||||||
|
$markup = match ($size) {
|
||||||
|
'full' => $this->render_markup,
|
||||||
|
'half_horizontal' => $this->render_markup_half_horizontal ?? $this->render_markup,
|
||||||
|
'half_vertical' => $this->render_markup_half_vertical ?? $this->render_markup,
|
||||||
|
'quadrant' => $this->render_markup_quadrant ?? $this->render_markup,
|
||||||
|
default => $this->render_markup,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepend shared markup if it exists
|
||||||
|
if ($markup && $this->render_markup_shared) {
|
||||||
|
$markup = $this->render_markup_shared."\n".$markup;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $markup;
|
||||||
|
}
|
||||||
|
|
||||||
public function getPreviewMashupLayoutForSize(string $size): string
|
public function getPreviewMashupLayoutForSize(string $size): string
|
||||||
{
|
{
|
||||||
return match ($size) {
|
return match ($size) {
|
||||||
|
|
@ -564,4 +686,61 @@ class Plugin extends Model
|
||||||
default => '1Tx1B',
|
default => '1Tx1B',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate the plugin, copying all attributes and handling render_markup_view
|
||||||
|
*
|
||||||
|
* @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
|
||||||
|
* @return Plugin The newly created duplicate plugin
|
||||||
|
*/
|
||||||
|
public function duplicate(?int $userId = null): self
|
||||||
|
{
|
||||||
|
// Get all attributes except id and uuid
|
||||||
|
// Use toArray() to get cast values (respects JSON casts)
|
||||||
|
$attributes = $this->toArray();
|
||||||
|
unset($attributes['id'], $attributes['uuid'], $attributes['trmnlp_id']);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,13 @@ 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;
|
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
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;
|
||||||
|
|
@ -15,7 +16,7 @@ class AppServiceProvider extends ServiceProvider
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
//
|
$this->app->bind('qr-code', fn () => new QrCodeService);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
72
app/Providers/FortifyServiceProvider.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
<?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'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -26,11 +26,44 @@ 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', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
|
||||||
|
$uuid = self::generateImageFromModel(
|
||||||
|
markup: $markup,
|
||||||
|
deviceModel: $device->deviceModel,
|
||||||
|
user: $device->user,
|
||||||
|
palette: $device->palette ?? $device->deviceModel?->palette,
|
||||||
|
device: $device
|
||||||
|
);
|
||||||
|
|
||||||
|
$device->update(['current_screen_image' => $uuid]);
|
||||||
|
Log::info("Device $device->id: updated with new image: $uuid");
|
||||||
|
|
||||||
|
return $uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an image from markup using a DeviceModel
|
||||||
|
*
|
||||||
|
* @param string $markup The HTML markup to render
|
||||||
|
* @param DeviceModel|null $deviceModel The device model to use for image generation
|
||||||
|
* @param \App\Models\User|null $user Optional user for timezone settings
|
||||||
|
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
|
||||||
|
* @param Device|null $device Optional device for legacy devices without DeviceModel
|
||||||
|
* @return string The UUID of the generated image
|
||||||
|
*/
|
||||||
|
public static function generateImageFromModel(
|
||||||
|
string $markup,
|
||||||
|
?DeviceModel $deviceModel = null,
|
||||||
|
?\App\Models\User $user = null,
|
||||||
|
?\App\Models\DevicePalette $palette = null,
|
||||||
|
?Device $device = null
|
||||||
|
): string {
|
||||||
$uuid = Uuid::uuid4()->toString();
|
$uuid = Uuid::uuid4()->toString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
||||||
$imageSettings = self::getImageSettings($device);
|
$imageSettings = $deviceModel instanceof DeviceModel
|
||||||
|
? 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);
|
||||||
|
|
@ -45,7 +78,7 @@ class ImageGenerationService
|
||||||
$browserStage->html($markup);
|
$browserStage->html($markup);
|
||||||
|
|
||||||
// Set timezone from user or fall back to app timezone
|
// Set timezone from user or fall back to app timezone
|
||||||
$timezone = $device->user->timezone ?? config('app.timezone');
|
$timezone = $user->timezone ?? config('app.timezone');
|
||||||
$browserStage->timezone($timezone);
|
$browserStage->timezone($timezone);
|
||||||
|
|
||||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||||
|
|
@ -65,12 +98,12 @@ 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
|
// Get palette from parameter or fallback to device model's default palette
|
||||||
$palette = $device->palette ?? $device->deviceModel?->palette;
|
|
||||||
$colorPalette = null;
|
$colorPalette = null;
|
||||||
|
|
||||||
if ($palette && $palette->colors) {
|
if ($palette && $palette->colors) {
|
||||||
$colorPalette = $palette->colors;
|
$colorPalette = $palette->colors;
|
||||||
|
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
|
||||||
|
$colorPalette = $deviceModel->palette->colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
$imageStage = new ImageStage();
|
$imageStage = new ImageStage();
|
||||||
|
|
@ -107,8 +140,7 @@ class ImageGenerationService
|
||||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
$device->update(['current_screen_image' => $uuid]);
|
Log::info("Generated image: $uuid");
|
||||||
Log::info("Device $device->id: updated with new image: $uuid");
|
|
||||||
|
|
||||||
return $uuid;
|
return $uuid;
|
||||||
|
|
||||||
|
|
@ -125,22 +157,7 @@ class ImageGenerationService
|
||||||
{
|
{
|
||||||
// If device has a DeviceModel, use its settings
|
// If device has a DeviceModel, use its settings
|
||||||
if ($device->deviceModel) {
|
if ($device->deviceModel) {
|
||||||
/** @var DeviceModel $model */
|
return self::getImageSettingsFromModel($device->deviceModel);
|
||||||
$model = $device->deviceModel;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'width' => $model->width,
|
|
||||||
'height' => $model->height,
|
|
||||||
'colors' => $model->colors,
|
|
||||||
'bit_depth' => $model->bit_depth,
|
|
||||||
'scale_factor' => $model->scale_factor,
|
|
||||||
'rotation' => $model->rotation,
|
|
||||||
'mime_type' => $model->mime_type,
|
|
||||||
'offset_x' => $model->offset_x,
|
|
||||||
'offset_y' => $model->offset_y,
|
|
||||||
'image_format' => self::determineImageFormatFromModel($model),
|
|
||||||
'use_model_settings' => true,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to device settings
|
// Fallback to device settings
|
||||||
|
|
@ -164,6 +181,43 @@ 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
|
||||||
*/
|
*/
|
||||||
|
|
@ -280,6 +334,10 @@ class ImageGenerationService
|
||||||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||||
{
|
{
|
||||||
if ($plugin?->id) {
|
if ($plugin?->id) {
|
||||||
|
// Image webhook plugins have finalized images that shouldn't be reset
|
||||||
|
if ($plugin->plugin_type === 'image_webhook') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
// Check if any devices have custom dimensions or use non-standard DeviceModels
|
||||||
$hasCustomDimensions = Device::query()
|
$hasCustomDimensions = Device::query()
|
||||||
->where(function ($query): void {
|
->where(function ($query): void {
|
||||||
|
|
@ -311,7 +369,7 @@ class ImageGenerationService
|
||||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -345,10 +403,10 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate a default screen image from Blade template
|
* Generate a default screen image from Blade template
|
||||||
*/
|
*/
|
||||||
public static function generateDefaultScreenImage(Device $device, string $imageType): string
|
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Validate image type
|
// Validate image type
|
||||||
if (! in_array($imageType, ['setup-logo', 'sleep'])) {
|
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -365,7 +423,7 @@ class ImageGenerationService
|
||||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||||
|
|
||||||
// Generate HTML from Blade template
|
// Generate HTML from Blade template
|
||||||
$html = self::generateDefaultScreenHtml($device, $imageType);
|
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||||
|
|
||||||
// Create custom Browsershot instance if using AWS Lambda
|
// Create custom Browsershot instance if using AWS Lambda
|
||||||
$browsershotInstance = null;
|
$browsershotInstance = null;
|
||||||
|
|
@ -445,12 +503,13 @@ class ImageGenerationService
|
||||||
/**
|
/**
|
||||||
* Generate HTML from Blade template for default screens
|
* Generate HTML from Blade template for default screens
|
||||||
*/
|
*/
|
||||||
private static function generateDefaultScreenHtml(Device $device, string $imageType): string
|
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||||
{
|
{
|
||||||
// Map image type to template name
|
// Map image type to template name
|
||||||
$templateName = match ($imageType) {
|
$templateName = match ($imageType) {
|
||||||
'setup-logo' => 'default-screens.setup',
|
'setup-logo' => 'default-screens.setup',
|
||||||
'sleep' => 'default-screens.sleep',
|
'sleep' => 'default-screens.sleep',
|
||||||
|
'error' => 'default-screens.error',
|
||||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -461,14 +520,22 @@ class ImageGenerationService
|
||||||
$scaleLevel = $device->scaleLevel();
|
$scaleLevel = $device->scaleLevel();
|
||||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||||
|
|
||||||
// Render the Blade template
|
// Build view data
|
||||||
return view($templateName, [
|
$viewData = [
|
||||||
'noBleed' => false,
|
'noBleed' => false,
|
||||||
'darkMode' => $darkMode,
|
'darkMode' => $darkMode,
|
||||||
'deviceVariant' => $deviceVariant,
|
'deviceVariant' => $deviceVariant,
|
||||||
'deviceOrientation' => $deviceOrientation,
|
'deviceOrientation' => $deviceOrientation,
|
||||||
'colorDepth' => $colorDepth,
|
'colorDepth' => $colorDepth,
|
||||||
'scaleLevel' => $scaleLevel,
|
'scaleLevel' => $scaleLevel,
|
||||||
])->render();
|
];
|
||||||
|
|
||||||
|
// Add plugin name for error screens
|
||||||
|
if ($imageType === 'error' && $pluginName !== null) {
|
||||||
|
$viewData['pluginName'] = $pluginName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the Blade template
|
||||||
|
return view($templateName, $viewData)->render();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class IcalResponseParser implements ResponseParser
|
||||||
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
||||||
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
||||||
|
|
||||||
if (!$startDate instanceof \Carbon\Carbon) {
|
if (! $startDate instanceof Carbon) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ class XmlResponseParser implements ResponseParser
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$xml = simplexml_load_string($response->body());
|
$xml = $this->simplexml_load_string_strip_namespaces($response->body());
|
||||||
if ($xml === false) {
|
if ($xml === false) {
|
||||||
throw new Exception('Invalid XML content');
|
throw new Exception('Invalid XML content');
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['rss' => $this->xmlToArray($xml)];
|
return [$xml->getName() => $this->xmlToArray($xml)];
|
||||||
} catch (Exception $exception) {
|
} catch (Exception $exception) {
|
||||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||||
|
|
||||||
|
|
@ -43,4 +43,25 @@ class XmlResponseParser implements ResponseParser
|
||||||
|
|
||||||
return $array;
|
return $array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function simplexml_load_string_strip_namespaces($xml_response) {
|
||||||
|
$xml = simplexml_load_string($xml_response);
|
||||||
|
if ($xml === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$namespaces = array_keys($xml->getDocNamespaces(true));
|
||||||
|
$namespaces = array_filter($namespaces, function($name) { return !empty($name); });
|
||||||
|
if (count($namespaces) == 0) {
|
||||||
|
return $xml;
|
||||||
|
}
|
||||||
|
$namespaces = array_map(function($ns) { return "$ns:"; }, $namespaces);
|
||||||
|
|
||||||
|
$xml_no_namespaces = str_replace(
|
||||||
|
array_merge(["xmlns="], $namespaces),
|
||||||
|
array_merge(["ns="], array_fill(0, count($namespaces), '')),
|
||||||
|
$xml_response
|
||||||
|
);
|
||||||
|
return simplexml_load_string($xml_no_namespaces);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,35 @@ class PluginExportService
|
||||||
$settings = $this->generateSettingsYaml($plugin);
|
$settings = $this->generateSettingsYaml($plugin);
|
||||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||||
// Generate full template content
|
|
||||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
|
||||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
|
||||||
// Generate shared.liquid if needed (for liquid templates)
|
// Export full template if it exists
|
||||||
if ($plugin->markup_language === 'liquid') {
|
if ($plugin->render_markup) {
|
||||||
$sharedTemplate = $this->generateSharedTemplate();
|
$fullTemplate = $this->generateLayoutTemplate($plugin->render_markup);
|
||||||
/** @phpstan-ignore-next-line */
|
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||||
if ($sharedTemplate) {
|
}
|
||||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
|
||||||
}
|
// Export layout-specific templates if they exist
|
||||||
|
if ($plugin->render_markup_half_horizontal) {
|
||||||
|
$halfHorizontalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_horizontal);
|
||||||
|
File::put($tempDir.'/half_horizontal.'.$extension, $halfHorizontalTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($plugin->render_markup_half_vertical) {
|
||||||
|
$halfVerticalTemplate = $this->generateLayoutTemplate($plugin->render_markup_half_vertical);
|
||||||
|
File::put($tempDir.'/half_vertical.'.$extension, $halfVerticalTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($plugin->render_markup_quadrant) {
|
||||||
|
$quadrantTemplate = $this->generateLayoutTemplate($plugin->render_markup_quadrant);
|
||||||
|
File::put($tempDir.'/quadrant.'.$extension, $quadrantTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export shared template if it exists
|
||||||
|
if ($plugin->render_markup_shared) {
|
||||||
|
$sharedTemplate = $this->generateLayoutTemplate($plugin->render_markup_shared);
|
||||||
|
File::put($tempDir.'/shared.'.$extension, $sharedTemplate);
|
||||||
}
|
}
|
||||||
// Create ZIP file
|
// Create ZIP file
|
||||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||||
|
|
@ -124,29 +142,21 @@ class PluginExportService
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the full template content
|
* Generate template content from markup, removing wrapper divs if present
|
||||||
*/
|
*/
|
||||||
private function generateFullTemplate(Plugin $plugin): string
|
private function generateLayoutTemplate(?string $markup): string
|
||||||
{
|
{
|
||||||
$markup = $plugin->render_markup;
|
if (! $markup) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
// Remove the wrapper div if it exists (it will be added during import)
|
// Remove the wrapper div if it exists (it will be added during import for liquid)
|
||||||
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
||||||
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
||||||
|
|
||||||
return mb_trim($markup);
|
return mb_trim($markup);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate the shared template content (for liquid templates)
|
|
||||||
*/
|
|
||||||
private function generateSharedTemplate(): null
|
|
||||||
{
|
|
||||||
// For now, we don't have a way to store shared templates separately
|
|
||||||
// TODO - add support for shared templates
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a directory and its contents to a ZIP file
|
* Add a directory and its contents to a ZIP file
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,34 @@ use ZipArchive;
|
||||||
|
|
||||||
class PluginImportService
|
class PluginImportService
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Validate YAML settings
|
||||||
|
*
|
||||||
|
* @param array $settings The parsed YAML settings
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
private function validateYAML(array $settings): void
|
||||||
|
{
|
||||||
|
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($settings['custom_fields'] as $field) {
|
||||||
|
if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
|
||||||
|
|
||||||
|
if (isset($field['default']) && str_contains((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
|
||||||
*
|
*
|
||||||
|
|
@ -47,32 +75,77 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
if (! $filePaths['settingsYamlPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid are missing.'); // full.blade.php
|
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have at least one template file
|
||||||
|
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||||
|
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse settings.yml
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Read full.liquid content
|
// Determine markup language from the first available file
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
$markupLanguage = 'blade';
|
||||||
|
$firstTemplatePath = $filePaths['fullLiquidPath']
|
||||||
|
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['quadrantLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['sharedLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['sharedBladePath'] ?? null);
|
||||||
|
|
||||||
// Prepend shared.liquid content if available
|
if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
$markupLanguage = 'liquid';
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file ends with .liquid to set markup language
|
// Read full markup (don't prepend shared - it will be prepended at render time)
|
||||||
$markupLanguage = 'blade';
|
$fullLiquid = null;
|
||||||
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
|
||||||
$markupLanguage = 'liquid';
|
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read shared markup separately
|
||||||
|
$sharedMarkup = null;
|
||||||
|
if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
|
$sharedMarkup = File::get($filePaths['sharedLiquidPath']);
|
||||||
|
} elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||||
|
$sharedMarkup = File::get($filePaths['sharedBladePath']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read layout-specific markups
|
||||||
|
$halfHorizontalMarkup = null;
|
||||||
|
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
|
||||||
|
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$halfVerticalMarkup = null;
|
||||||
|
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
|
||||||
|
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$quadrantMarkup = null;
|
||||||
|
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
|
||||||
|
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -108,7 +181,11 @@ 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,
|
'render_markup' => $fullLiquid ?? null,
|
||||||
|
'render_markup_half_horizontal' => $halfHorizontalMarkup,
|
||||||
|
'render_markup_half_vertical' => $halfVerticalMarkup,
|
||||||
|
'render_markup_quadrant' => $quadrantMarkup,
|
||||||
|
'render_markup_shared' => $sharedMarkup,
|
||||||
'configuration_template' => $configurationTemplate,
|
'configuration_template' => $configurationTemplate,
|
||||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||||
]);
|
]);
|
||||||
|
|
@ -144,11 +221,12 @@ class PluginImportService
|
||||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||||
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
||||||
|
* @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
|
||||||
* @return Plugin The created plugin instance
|
* @return Plugin The created plugin instance
|
||||||
*
|
*
|
||||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||||
*/
|
*/
|
||||||
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin
|
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
|
||||||
{
|
{
|
||||||
// Download the ZIP file
|
// Download the ZIP file
|
||||||
$response = Http::timeout(60)->get($zipUrl);
|
$response = Http::timeout(60)->get($zipUrl);
|
||||||
|
|
@ -176,32 +254,77 @@ class PluginImportService
|
||||||
$zip->extractTo($tempDir);
|
$zip->extractTo($tempDir);
|
||||||
$zip->close();
|
$zip->close();
|
||||||
|
|
||||||
// Find the required files (settings.yml and full.liquid/full.blade.php)
|
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||||
|
|
||||||
// Validate that we found the required files
|
// Validate that we found the required files
|
||||||
if (! $filePaths['settingsYamlPath'] || ! $filePaths['fullLiquidPath']) {
|
if (! $filePaths['settingsYamlPath']) {
|
||||||
throw new Exception('Invalid ZIP structure. Required files settings.yml and full.liquid/full.blade.php are missing.');
|
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we have at least one template file
|
||||||
|
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||||
|
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse settings.yml
|
// Parse settings.yml
|
||||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||||
$settings = Yaml::parse($settingsYaml);
|
$settings = Yaml::parse($settingsYaml);
|
||||||
|
$this->validateYAML($settings);
|
||||||
|
|
||||||
// Read full.liquid content
|
// Determine markup language from the first available file
|
||||||
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
$markupLanguage = 'blade';
|
||||||
|
$firstTemplatePath = $filePaths['fullLiquidPath']
|
||||||
|
?? ($filePaths['halfHorizontalLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['halfVerticalLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['quadrantLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['sharedLiquidPath'] ?? null)
|
||||||
|
?? ($filePaths['sharedBladePath'] ?? null);
|
||||||
|
|
||||||
// Prepend shared.liquid content if available
|
if ($firstTemplatePath && pathinfo((string) $firstTemplatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
$markupLanguage = 'liquid';
|
||||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
|
||||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the file ends with .liquid to set markup language
|
// Read full markup (don't prepend shared - it will be prepended at render time)
|
||||||
$markupLanguage = 'blade';
|
$fullLiquid = null;
|
||||||
if (pathinfo((string) $filePaths['fullLiquidPath'], PATHINFO_EXTENSION) === 'liquid') {
|
if (isset($filePaths['fullLiquidPath']) && $filePaths['fullLiquidPath']) {
|
||||||
$markupLanguage = 'liquid';
|
$fullLiquid = File::get($filePaths['fullLiquidPath']);
|
||||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read shared markup separately
|
||||||
|
$sharedMarkup = null;
|
||||||
|
if (isset($filePaths['sharedLiquidPath']) && $filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||||
|
$sharedMarkup = File::get($filePaths['sharedLiquidPath']);
|
||||||
|
} elseif (isset($filePaths['sharedBladePath']) && $filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||||
|
$sharedMarkup = File::get($filePaths['sharedBladePath']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read layout-specific markups
|
||||||
|
$halfHorizontalMarkup = null;
|
||||||
|
if (isset($filePaths['halfHorizontalLiquidPath']) && $filePaths['halfHorizontalLiquidPath'] && File::exists($filePaths['halfHorizontalLiquidPath'])) {
|
||||||
|
$halfHorizontalMarkup = File::get($filePaths['halfHorizontalLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$halfHorizontalMarkup = '<div class="view view--{{ size }}">'."\n".$halfHorizontalMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$halfVerticalMarkup = null;
|
||||||
|
if (isset($filePaths['halfVerticalLiquidPath']) && $filePaths['halfVerticalLiquidPath'] && File::exists($filePaths['halfVerticalLiquidPath'])) {
|
||||||
|
$halfVerticalMarkup = File::get($filePaths['halfVerticalLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$halfVerticalMarkup = '<div class="view view--{{ size }}">'."\n".$halfVerticalMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$quadrantMarkup = null;
|
||||||
|
if (isset($filePaths['quadrantLiquidPath']) && $filePaths['quadrantLiquidPath'] && File::exists($filePaths['quadrantLiquidPath'])) {
|
||||||
|
$quadrantMarkup = File::get($filePaths['quadrantLiquidPath']);
|
||||||
|
if ($markupLanguage === 'liquid') {
|
||||||
|
$quadrantMarkup = '<div class="view view--{{ size }}">'."\n".$quadrantMarkup."\n".'</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure custom_fields is properly formatted
|
// Ensure custom_fields is properly formatted
|
||||||
|
|
@ -217,17 +340,26 @@ class PluginImportService
|
||||||
'custom_fields' => $settings['custom_fields'],
|
'custom_fields' => $settings['custom_fields'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$plugin_updated = isset($settings['id'])
|
// Determine the trmnlp_id to use
|
||||||
|
$trmnlpId = $settings['id'] ?? Uuid::v7();
|
||||||
|
|
||||||
|
// If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
|
||||||
|
if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
|
||||||
|
$trmnlpId = Uuid::v7();
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin_updated = ! $allowDuplicate && isset($settings['id'])
|
||||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||||
|
|
||||||
// Create a new plugin
|
// Create a new plugin
|
||||||
$plugin = Plugin::updateOrCreate(
|
$plugin = Plugin::updateOrCreate(
|
||||||
[
|
[
|
||||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
'trmnlp_id' => $trmnlpId,
|
||||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||||
'polling_url' => $settings['polling_url'] ?? null,
|
'polling_url' => $settings['polling_url'] ?? null,
|
||||||
|
|
@ -237,7 +369,11 @@ 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,
|
'render_markup' => $fullLiquid ?? null,
|
||||||
|
'render_markup_half_horizontal' => $halfHorizontalMarkup,
|
||||||
|
'render_markup_half_vertical' => $halfVerticalMarkup,
|
||||||
|
'render_markup_quadrant' => $quadrantMarkup,
|
||||||
|
'render_markup_shared' => $sharedMarkup,
|
||||||
'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,
|
||||||
|
|
@ -272,6 +408,10 @@ class PluginImportService
|
||||||
$settingsYamlPath = null;
|
$settingsYamlPath = null;
|
||||||
$fullLiquidPath = null;
|
$fullLiquidPath = null;
|
||||||
$sharedLiquidPath = null;
|
$sharedLiquidPath = null;
|
||||||
|
$sharedBladePath = null;
|
||||||
|
$halfHorizontalLiquidPath = null;
|
||||||
|
$halfVerticalLiquidPath = null;
|
||||||
|
$quadrantLiquidPath = 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) {
|
||||||
|
|
@ -289,6 +429,27 @@ 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for layout-specific files
|
||||||
|
if (File::exists($targetDir.'/half_horizontal.liquid')) {
|
||||||
|
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/half_horizontal.blade.php')) {
|
||||||
|
$halfHorizontalLiquidPath = $targetDir.'/half_horizontal.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($targetDir.'/half_vertical.liquid')) {
|
||||||
|
$halfVerticalLiquidPath = $targetDir.'/half_vertical.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/half_vertical.blade.php')) {
|
||||||
|
$halfVerticalLiquidPath = $targetDir.'/half_vertical.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($targetDir.'/quadrant.liquid')) {
|
||||||
|
$quadrantLiquidPath = $targetDir.'/quadrant.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/quadrant.blade.php')) {
|
||||||
|
$quadrantLiquidPath = $targetDir.'/quadrant.blade.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -304,15 +465,37 @@ 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for layout-specific files in src
|
||||||
|
if (File::exists($targetDir.'/src/half_horizontal.liquid')) {
|
||||||
|
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/src/half_horizontal.blade.php')) {
|
||||||
|
$halfHorizontalLiquidPath = $targetDir.'/src/half_horizontal.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($targetDir.'/src/half_vertical.liquid')) {
|
||||||
|
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/src/half_vertical.blade.php')) {
|
||||||
|
$halfVerticalLiquidPath = $targetDir.'/src/half_vertical.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($targetDir.'/src/quadrant.liquid')) {
|
||||||
|
$quadrantLiquidPath = $targetDir.'/src/quadrant.liquid';
|
||||||
|
} elseif (File::exists($targetDir.'/src/quadrant.blade.php')) {
|
||||||
|
$quadrantLiquidPath = $targetDir.'/src/quadrant.blade.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found the required files in the target directory, return them
|
// If we found the required files in the target directory, return them
|
||||||
if ($settingsYamlPath && $fullLiquidPath) {
|
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||||
return [
|
return [
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
|
'sharedBladePath' => $sharedBladePath,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -329,9 +512,30 @@ class PluginImportService
|
||||||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for shared.liquid in the same directory
|
// Check for shared.liquid or shared.blade.php in the same directory
|
||||||
if (File::exists($tempDir.'/src/shared.liquid')) {
|
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||||
|
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for layout-specific files
|
||||||
|
if (File::exists($tempDir.'/src/half_horizontal.liquid')) {
|
||||||
|
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/half_horizontal.blade.php')) {
|
||||||
|
$halfHorizontalLiquidPath = $tempDir.'/src/half_horizontal.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($tempDir.'/src/half_vertical.liquid')) {
|
||||||
|
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/half_vertical.blade.php')) {
|
||||||
|
$halfVerticalLiquidPath = $tempDir.'/src/half_vertical.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (File::exists($tempDir.'/src/quadrant.liquid')) {
|
||||||
|
$quadrantLiquidPath = $tempDir.'/src/quadrant.liquid';
|
||||||
|
} elseif (File::exists($tempDir.'/src/quadrant.blade.php')) {
|
||||||
|
$quadrantLiquidPath = $tempDir.'/src/quadrant.blade.php';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Search for the files in the extracted directory structure
|
// Search for the files in the extracted directory structure
|
||||||
|
|
@ -348,20 +552,30 @@ 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;
|
||||||
|
} elseif ($filename === 'half_horizontal.liquid' || $filename === 'half_horizontal.blade.php') {
|
||||||
|
$halfHorizontalLiquidPath = $filepath;
|
||||||
|
} elseif ($filename === 'half_vertical.liquid' || $filename === 'half_vertical.blade.php') {
|
||||||
|
$halfVerticalLiquidPath = $filepath;
|
||||||
|
} elseif ($filename === 'quadrant.liquid' || $filename === 'quadrant.blade.php') {
|
||||||
|
$quadrantLiquidPath = $filepath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if shared.liquid exists in the same directory as full.liquid
|
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
||||||
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath) {
|
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
||||||
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
||||||
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
||||||
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
|
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
|
||||||
|
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
|
||||||
|
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we found the files but they're not in the src folder,
|
// If we found the files but they're not in the src folder,
|
||||||
// check if they're in the root of the ZIP or in a subfolder
|
// check if they're in the root of the ZIP or in a subfolder
|
||||||
if ($settingsYamlPath && $fullLiquidPath) {
|
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||||
// If the files are in the root of the ZIP, create a src folder and move them there
|
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||||
$srcDir = dirname((string) $settingsYamlPath);
|
$srcDir = dirname((string) $settingsYamlPath);
|
||||||
|
|
||||||
|
|
@ -372,17 +586,44 @@ class PluginImportService
|
||||||
|
|
||||||
// Copy the files to the src directory
|
// Copy the files to the src directory
|
||||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||||
File::copy($fullLiquidPath, $newSrcDir.'/full.liquid');
|
|
||||||
|
|
||||||
// Copy shared.liquid if it exists
|
// Copy full.liquid or full.blade.php if it exists
|
||||||
|
if ($fullLiquidPath) {
|
||||||
|
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
|
||||||
|
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
|
||||||
|
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy shared.liquid or shared.blade.php if it exists
|
||||||
if ($sharedLiquidPath) {
|
if ($sharedLiquidPath) {
|
||||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||||
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
||||||
|
} elseif ($sharedBladePath) {
|
||||||
|
File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
|
||||||
|
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy layout-specific files if they exist
|
||||||
|
if ($halfHorizontalLiquidPath) {
|
||||||
|
$extension = pathinfo((string) $halfHorizontalLiquidPath, PATHINFO_EXTENSION);
|
||||||
|
File::copy($halfHorizontalLiquidPath, $newSrcDir.'/half_horizontal.'.$extension);
|
||||||
|
$halfHorizontalLiquidPath = $newSrcDir.'/half_horizontal.'.$extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($halfVerticalLiquidPath) {
|
||||||
|
$extension = pathinfo((string) $halfVerticalLiquidPath, PATHINFO_EXTENSION);
|
||||||
|
File::copy($halfVerticalLiquidPath, $newSrcDir.'/half_vertical.'.$extension);
|
||||||
|
$halfVerticalLiquidPath = $newSrcDir.'/half_vertical.'.$extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($quadrantLiquidPath) {
|
||||||
|
$extension = pathinfo((string) $quadrantLiquidPath, PATHINFO_EXTENSION);
|
||||||
|
File::copy($quadrantLiquidPath, $newSrcDir.'/quadrant.'.$extension);
|
||||||
|
$quadrantLiquidPath = $newSrcDir.'/quadrant.'.$extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the paths
|
// Update the paths
|
||||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||||
$fullLiquidPath = $newSrcDir.'/full.liquid';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +632,10 @@ class PluginImportService
|
||||||
'settingsYamlPath' => $settingsYamlPath,
|
'settingsYamlPath' => $settingsYamlPath,
|
||||||
'fullLiquidPath' => $fullLiquidPath,
|
'fullLiquidPath' => $fullLiquidPath,
|
||||||
'sharedLiquidPath' => $sharedLiquidPath,
|
'sharedLiquidPath' => $sharedLiquidPath,
|
||||||
|
'sharedBladePath' => $sharedBladePath,
|
||||||
|
'halfHorizontalLiquidPath' => $halfHorizontalLiquidPath,
|
||||||
|
'halfVerticalLiquidPath' => $halfVerticalLiquidPath,
|
||||||
|
'quadrantLiquidPath' => $quadrantLiquidPath,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
142
app/Services/QrCodeService.php
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, 0);
|
||||||
|
|
||||||
|
// 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
app/Settings/UpdateSettings.php
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Settings;
|
||||||
|
|
||||||
|
use Spatie\LaravelSettings\Settings;
|
||||||
|
|
||||||
|
class UpdateSettings extends Settings
|
||||||
|
{
|
||||||
|
public bool $prereleases = false;
|
||||||
|
|
||||||
|
public static function group(): string
|
||||||
|
{
|
||||||
|
return 'update';
|
||||||
|
}
|
||||||
|
}
|
||||||
15
boost.json
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"agents": [
|
|
||||||
"claude_code",
|
|
||||||
"copilot",
|
|
||||||
"cursor",
|
|
||||||
"phpstorm"
|
|
||||||
],
|
|
||||||
"editors": [
|
|
||||||
"claude_code",
|
|
||||||
"cursor",
|
|
||||||
"phpstorm",
|
|
||||||
"vscode"
|
|
||||||
],
|
|
||||||
"guidelines": []
|
|
||||||
}
|
|
||||||
|
|
@ -2,6 +2,5 @@
|
||||||
|
|
||||||
return [
|
return [
|
||||||
App\Providers\AppServiceProvider::class,
|
App\Providers\AppServiceProvider::class,
|
||||||
App\Providers\FolioServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
App\Providers\VoltServiceProvider::class,
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"trmnl",
|
"trmnl",
|
||||||
"trmnl-server",
|
"trmnl-server",
|
||||||
|
"trmnl-byos",
|
||||||
"laravel"
|
"laravel"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
@ -14,24 +15,27 @@
|
||||||
"ext-imagick": "*",
|
"ext-imagick": "*",
|
||||||
"ext-simplexml": "*",
|
"ext-simplexml": "*",
|
||||||
"ext-zip": "*",
|
"ext-zip": "*",
|
||||||
"bnussbau/laravel-trmnl-blade": "2.0.*",
|
"bnussbau/laravel-trmnl-blade": "2.3.*",
|
||||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
"bnussbau/trmnl-pipeline-php": "0.7.*",
|
||||||
"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/volt": "^1.7",
|
"livewire/livewire": "^4.0",
|
||||||
"om/icalparser": "^3.2",
|
"om/icalparser": "^3.2",
|
||||||
"spatie/browsershot": "^5.0",
|
"spatie/browsershot": "^5.0",
|
||||||
|
"spatie/laravel-settings": "^3.6",
|
||||||
|
"stevebauman/purify": "^6.3",
|
||||||
"symfony/yaml": "^7.3",
|
"symfony/yaml": "^7.3",
|
||||||
"wnx/sidecar-browsershot": "^2.6"
|
"wnx/sidecar-browsershot": "^2.6"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"larastan/larastan": "^3.0",
|
"larastan/larastan": "^3.0",
|
||||||
"laravel/boost": "^1.0",
|
"laravel/boost": "^2.0",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.18",
|
"laravel/pint": "^1.18",
|
||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
|
|
|
||||||
1858
composer.lock
generated
|
|
@ -153,4 +153,6 @@ return [
|
||||||
'version' => env('APP_VERSION', null),
|
'version' => env('APP_VERSION', null),
|
||||||
|
|
||||||
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
||||||
|
|
||||||
|
'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ return [
|
||||||
| Password Confirmation Timeout
|
| Password Confirmation Timeout
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
|
||||||
| Here you may define the amount of seconds before a password confirmation
|
| Here you may define the number 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.
|
||||||
|
|
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ 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", "null"
|
| "redis", "dynamodb", "octane",
|
||||||
|
| "failover", "null"
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -90,6 +91,14 @@ return [
|
||||||
'driver' => 'octane',
|
'driver' => 'octane',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'stores' => [
|
||||||
|
'database',
|
||||||
|
'array',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -103,6 +112,6 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'),
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ return [
|
||||||
'busy_timeout' => null,
|
'busy_timeout' => null,
|
||||||
'journal_mode' => null,
|
'journal_mode' => null,
|
||||||
'synchronous' => null,
|
'synchronous' => null,
|
||||||
|
'transaction_mode' => 'DEFERRED',
|
||||||
],
|
],
|
||||||
|
|
||||||
'mysql' => [
|
'mysql' => [
|
||||||
|
|
@ -58,7 +59,7 @@ return [
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -78,7 +79,7 @@ return [
|
||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
(PHP_VERSION_ID >= 80500 ? Pdo\Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -94,7 +95,7 @@ return [
|
||||||
'prefix' => '',
|
'prefix' => '',
|
||||||
'prefix_indexes' => true,
|
'prefix_indexes' => true,
|
||||||
'search_path' => 'public',
|
'search_path' => 'public',
|
||||||
'sslmode' => 'prefer',
|
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||||
],
|
],
|
||||||
|
|
||||||
'sqlsrv' => [
|
'sqlsrv' => [
|
||||||
|
|
@ -147,7 +148,7 @@ return [
|
||||||
|
|
||||||
'options' => [
|
'options' => [
|
||||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_database_'),
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||||
'persistent' => env('REDIS_PERSISTENT', false),
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
@ -158,6 +159,10 @@ 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' => [
|
||||||
|
|
@ -167,6 +172,10 @@ 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),
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -35,14 +35,16 @@ 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' => env('APP_URL').'/storage',
|
'url' => mb_rtrim(env('APP_URL'), '/').'/storage',
|
||||||
'visibility' => 'public',
|
'visibility' => 'public',
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
's3' => [
|
's3' => [
|
||||||
|
|
@ -55,6 +57,7 @@ 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,
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
|
||||||
159
config/fortify.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?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,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
277
config/livewire.php
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
<?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
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -54,7 +54,7 @@ return [
|
||||||
|
|
||||||
'stack' => [
|
'stack' => [
|
||||||
'driver' => 'stack',
|
'driver' => 'stack',
|
||||||
'channels' => explode(',', env('LOG_STACK', 'single')),
|
'channels' => explode(',', (string) 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,
|
||||||
'formatter' => env('LOG_STDOUT_FORMATTER'),
|
'handler_with' => [
|
||||||
'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,
|
||||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
'handler_with' => [
|
||||||
'with' => [
|
|
||||||
'stream' => 'php://stderr',
|
'stream' => 'php://stderr',
|
||||||
],
|
],
|
||||||
|
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||||
'processors' => [PsrLogMessageProcessor::class],
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||||
],
|
],
|
||||||
|
|
||||||
'ses' => [
|
'ses' => [
|
||||||
|
|
@ -85,6 +85,7 @@ return [
|
||||||
'smtp',
|
'smtp',
|
||||||
'log',
|
'log',
|
||||||
],
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
'roundrobin' => [
|
'roundrobin' => [
|
||||||
|
|
@ -93,6 +94,7 @@ return [
|
||||||
'ses',
|
'ses',
|
||||||
'postmark',
|
'postmark',
|
||||||
],
|
],
|
||||||
|
'retry_after' => 60,
|
||||||
],
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ 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", "null"
|
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||||
|
| "deferred", "background", "failover", "null"
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -72,6 +73,22 @@ return [
|
||||||
'after_commit' => false,
|
'after_commit' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'deferred' => [
|
||||||
|
'driver' => 'deferred',
|
||||||
|
],
|
||||||
|
|
||||||
|
'background' => [
|
||||||
|
'driver' => 'background',
|
||||||
|
],
|
||||||
|
|
||||||
|
'failover' => [
|
||||||
|
'driver' => 'failover',
|
||||||
|
'connections' => [
|
||||||
|
'database',
|
||||||
|
'deferred',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,11 @@ return [
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'postmark' => [
|
'postmark' => [
|
||||||
'token' => env('POSTMARK_TOKEN'),
|
'key' => env('POSTMARK_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'resend' => [
|
||||||
|
'key' => env('RESEND_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
'ses' => [
|
'ses' => [
|
||||||
|
|
@ -24,10 +28,6 @@ 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'),
|
||||||
|
|
@ -36,6 +36,7 @@ return [
|
||||||
],
|
],
|
||||||
|
|
||||||
'trmnl' => [
|
'trmnl' => [
|
||||||
|
'base_url' => 'https://trmnl.com',
|
||||||
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
|
'proxy_base_url' => env('TRMNL_PROXY_BASE_URL', 'https://trmnl.app'),
|
||||||
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
||||||
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
||||||
|
|
|
||||||
|
|
@ -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", "apc",
|
| Supported: "file", "cookie", "database", "memcached",
|
||||||
| "memcached", "redis", "dynamodb", "array"
|
| "redis", "dynamodb", "array"
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ return [
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'lifetime' => env('SESSION_LIFETIME', 120),
|
'lifetime' => (int) 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: "apc", "dynamodb", "memcached", "redis"
|
| Affects: "dynamodb", "memcached", "redis"
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -129,7 +129,7 @@ return [
|
||||||
|
|
||||||
'cookie' => env(
|
'cookie' => env(
|
||||||
'SESSION_COOKIE',
|
'SESSION_COOKIE',
|
||||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
|
Str::slug((string) 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 and all subdomains. Typically, this shouldn't be changed.
|
| domain without subdomains. Typically, this shouldn't be changed.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
|
||||||
94
config/settings.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Each settings class used in your application must be registered, you can
|
||||||
|
* put them (manually) here.
|
||||||
|
*/
|
||||||
|
'settings' => [
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The path where the settings classes will be created.
|
||||||
|
*/
|
||||||
|
'setting_class_path' => app_path('Settings'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
* In these directories settings migrations will be stored and ran when migrating. A settings
|
||||||
|
* migration created via the make:settings-migration command will be stored in the first path or
|
||||||
|
* a custom defined path when running the command.
|
||||||
|
*/
|
||||||
|
'migrations_paths' => [
|
||||||
|
database_path('settings'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* When no repository was set for a settings class the following repository
|
||||||
|
* will be used for loading and saving settings.
|
||||||
|
*/
|
||||||
|
'default_repository' => 'database',
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Settings will be stored and loaded from these repositories.
|
||||||
|
*/
|
||||||
|
'repositories' => [
|
||||||
|
'database' => [
|
||||||
|
'type' => Spatie\LaravelSettings\SettingsRepositories\DatabaseSettingsRepository::class,
|
||||||
|
'model' => null,
|
||||||
|
'table' => null,
|
||||||
|
'connection' => null,
|
||||||
|
],
|
||||||
|
'redis' => [
|
||||||
|
'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class,
|
||||||
|
'connection' => null,
|
||||||
|
'prefix' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The encoder and decoder will determine how settings are stored and
|
||||||
|
* retrieved in the database. By default, `json_encode` and `json_decode`
|
||||||
|
* are used.
|
||||||
|
*/
|
||||||
|
'encoder' => null,
|
||||||
|
'decoder' => null,
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The contents of settings classes can be cached through your application,
|
||||||
|
* settings will be stored within a provided Laravel store and can have an
|
||||||
|
* additional prefix.
|
||||||
|
*/
|
||||||
|
'cache' => [
|
||||||
|
'enabled' => env('SETTINGS_CACHE_ENABLED', false),
|
||||||
|
'store' => null,
|
||||||
|
'prefix' => null,
|
||||||
|
'ttl' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* These global casts will be automatically used whenever a property within
|
||||||
|
* your settings class isn't a default PHP type.
|
||||||
|
*/
|
||||||
|
'global_casts' => [
|
||||||
|
DateTimeInterface::class => Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast::class,
|
||||||
|
DateTimeZone::class => Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast::class,
|
||||||
|
// Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class,
|
||||||
|
Spatie\LaravelData\Data::class => Spatie\LaravelSettings\SettingsCasts\DataCast::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The package will look for settings in these paths and automatically
|
||||||
|
* register them.
|
||||||
|
*/
|
||||||
|
'auto_discover_settings' => [
|
||||||
|
app_path('Settings'),
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Automatically discovered settings classes can be cached, so they don't
|
||||||
|
* need to be searched each time the application boots up.
|
||||||
|
*/
|
||||||
|
'discovered_settings_cache_path' => base_path('bootstrap/cache'),
|
||||||
|
];
|
||||||
6
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// Comma‑separated list from .env, e.g. "10.0.0.0/8,172.16.0.0/12" or '*'
|
||||||
|
'proxies' => ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
|
||||||
|
];
|
||||||
|
|
@ -20,7 +20,7 @@ class DevicePaletteFactory extends Factory
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => 'test-' . $this->faker->unique()->slug(),
|
'id' => 'test-'.$this->faker->unique()->slug(),
|
||||||
'name' => $this->faker->words(3, true),
|
'name' => $this->faker->words(3, true),
|
||||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||||
'colors' => $this->faker->optional()->passthrough([
|
'colors' => $this->faker->optional()->passthrough([
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,24 @@ class PluginFactory extends Factory
|
||||||
'icon_url' => null,
|
'icon_url' => null,
|
||||||
'flux_icon_name' => null,
|
'flux_icon_name' => null,
|
||||||
'author_name' => $this->faker->name(),
|
'author_name' => $this->faker->name(),
|
||||||
|
'plugin_type' => 'recipe',
|
||||||
'created_at' => Carbon::now(),
|
'created_at' => Carbon::now(),
|
||||||
'updated_at' => Carbon::now(),
|
'updated_at' => Carbon::now(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the plugin is an image webhook plugin.
|
||||||
|
*/
|
||||||
|
public function imageWebhook(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'plugin_type' => 'image_webhook',
|
||||||
|
'data_strategy' => 'static',
|
||||||
|
'data_stale_minutes' => 60,
|
||||||
|
'polling_url' => null,
|
||||||
|
'polling_verb' => 'get',
|
||||||
|
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ 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),
|
||||||
'assign_new_devices' => false,
|
'two_factor_secret' => null,
|
||||||
|
'two_factor_recovery_codes' => null,
|
||||||
|
'two_factor_confirmed_at' => null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,4 +44,16 @@ 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table): void {
|
||||||
|
$table->string('plugin_type')->default('recipe')->after('uuid');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('plugin_type');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\DeviceModel;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('device_models', function (Blueprint $table) {
|
||||||
|
$table->string('kind')->nullable()->index();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set existing og_png and og_plus to kind "trmnl"
|
||||||
|
DeviceModel::whereIn('name', ['og_png', 'og_plus'])->update(['kind' => 'trmnl']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('device_models', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['kind']);
|
||||||
|
$table->dropColumn('kind');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\Plugin;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Find and handle duplicate (user_id, trmnlp_id) combinations
|
||||||
|
$duplicates = Plugin::query()
|
||||||
|
->selectRaw('user_id, trmnlp_id, COUNT(*) as duplicate_count')
|
||||||
|
->whereNotNull('trmnlp_id')
|
||||||
|
->groupBy('user_id', 'trmnlp_id')
|
||||||
|
->havingRaw('COUNT(*) > ?', [1])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// For each duplicate combination, keep the first one (by id) and set others to null
|
||||||
|
foreach ($duplicates as $duplicate) {
|
||||||
|
$plugins = Plugin::query()
|
||||||
|
->where('user_id', $duplicate->user_id)
|
||||||
|
->where('trmnlp_id', $duplicate->trmnlp_id)
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Keep the first one, set the rest to null
|
||||||
|
$keepFirst = true;
|
||||||
|
foreach ($plugins as $plugin) {
|
||||||
|
if ($keepFirst) {
|
||||||
|
$keepFirst = false;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin->update(['trmnlp_id' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->unique(['user_id', 'trmnlp_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->dropUnique(['user_id', 'trmnlp_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->boolean('alias')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('alias');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
|
||||||
|
$table->string('group');
|
||||||
|
$table->string('name');
|
||||||
|
$table->boolean('locked')->default(false);
|
||||||
|
$table->json('payload');
|
||||||
|
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['group', 'name']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?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->text('render_markup_half_horizontal')->nullable()->after('render_markup');
|
||||||
|
$table->text('render_markup_half_vertical')->nullable()->after('render_markup_half_horizontal');
|
||||||
|
$table->text('render_markup_quadrant')->nullable()->after('render_markup_half_vertical');
|
||||||
|
$table->text('render_markup_shared')->nullable()->after('render_markup_quadrant');
|
||||||
|
$table->text('transform_code')->nullable()->after('render_markup_shared');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('plugins', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'render_markup_half_horizontal',
|
||||||
|
'render_markup_half_vertical',
|
||||||
|
'render_markup_quadrant',
|
||||||
|
'render_markup_shared',
|
||||||
|
'transform_code',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table): void {
|
||||||
|
$table->boolean('maximum_compatibility')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('devices', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('maximum_compatibility');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
@ -146,8 +146,8 @@ class ExampleRecipesSeeder extends Seeder
|
||||||
);
|
);
|
||||||
|
|
||||||
Plugin::updateOrCreate(
|
Plugin::updateOrCreate(
|
||||||
|
['uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90'],
|
||||||
[
|
[
|
||||||
'uuid' => '1d98bca4-837d-4b01-b1a1-e3b6e56eca90',
|
|
||||||
'name' => 'Holidays (iCal)',
|
'name' => 'Holidays (iCal)',
|
||||||
'user_id' => $user_id,
|
'user_id' => $user_id,
|
||||||
'data_payload' => null,
|
'data_payload' => null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
||||||
|
|
||||||
|
return new class extends SettingsMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->migrator->add('update.prereleases', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
808
package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/lang-json": "^6.0.2",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-liquid": "^6.3.0",
|
"@codemirror/lang-liquid": "^6.3.0",
|
||||||
|
"@codemirror/lang-yaml": "^6.1.2",
|
||||||
"@codemirror/language": "^6.11.3",
|
"@codemirror/language": "^6.11.3",
|
||||||
"@codemirror/search": "^6.5.11",
|
"@codemirror/search": "^6.5.11",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
|
|
|
||||||
BIN
public/mirror/assets/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
public/mirror/assets/apple-touch-icon-167x167.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/mirror/assets/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/mirror/assets/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/mirror/assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 401 B |
BIN
public/mirror/assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 518 B |
BIN
public/mirror/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
139
public/mirror/assets/logo--brand.svg
Normal file
|
After Width: | Height: | Size: 12 KiB |
907
public/mirror/index.html
Normal file
|
|
@ -0,0 +1,907 @@
|
||||||
|
<!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: {},
|
||||||
|
wakeLock: null,
|
||||||
|
|
||||||
|
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.displayModeSelect.value = data.display_mode || "";
|
||||||
|
trmnl.ui.fullscreenToggle.checked = !!data.fullscreen;
|
||||||
|
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock || !!data.wake_lock;
|
||||||
|
|
||||||
|
trmnl.ui.setup.style.display = "flex";
|
||||||
|
},
|
||||||
|
|
||||||
|
saveSetup: function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
var apiKey = trmnl.ui.apiKeyInput.value;
|
||||||
|
var baseURL = trmnl.ui.baseURLInput.value;
|
||||||
|
var displayMode = trmnl.ui.displayModeSelect.value;
|
||||||
|
var fullscreenEnabled = trmnl.ui.fullscreenToggle.checked;
|
||||||
|
var wakeLockEnabled = trmnl.ui.wakeLockToggle.checked;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trmnl.saveSettings({
|
||||||
|
api_key: apiKey,
|
||||||
|
base_url: baseURL,
|
||||||
|
display_mode: displayMode,
|
||||||
|
fullscreen: fullscreenEnabled,
|
||||||
|
wake_lock: wakeLockEnabled
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wakeLockEnabled) {
|
||||||
|
trmnl.acquireWakeLock().then(function () {
|
||||||
|
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.warn("Wake Lock request failed:", err);
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trmnl.releaseWakeLock().then(function () {
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.warn("Wake Lock release failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trmnl.fetchDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
hideSetupForm: function () {
|
||||||
|
trmnl.ui.setup.style.display = "none";
|
||||||
|
},
|
||||||
|
|
||||||
|
isFullscreenSupported: function () {
|
||||||
|
return !!(
|
||||||
|
document.fullscreenEnabled ||
|
||||||
|
document.webkitFullscreenEnabled ||
|
||||||
|
document.msFullscreenEnabled
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
isFullscreenActive: function () {
|
||||||
|
return !!(
|
||||||
|
document.fullscreenElement ||
|
||||||
|
document.webkitFullscreenElement ||
|
||||||
|
document.msFullscreenElement
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
enterFullscreen: function () {
|
||||||
|
if (!trmnl.isFullscreenSupported()) return;
|
||||||
|
|
||||||
|
var el = document.documentElement;
|
||||||
|
var promise;
|
||||||
|
|
||||||
|
if (el.requestFullscreen) {
|
||||||
|
promise = el.requestFullscreen();
|
||||||
|
} else if (el.webkitRequestFullscreen) {
|
||||||
|
promise = el.webkitRequestFullscreen();
|
||||||
|
} else if (el.msRequestFullscreen) {
|
||||||
|
promise = el.msRequestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promise && promise.catch) {
|
||||||
|
promise.catch(function (err) {
|
||||||
|
console.warn("Enter fullscreen failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exitFullscreen: function () {
|
||||||
|
if (!trmnl.isFullscreenSupported()) return;
|
||||||
|
if (!trmnl.isFullscreenActive()) return;
|
||||||
|
|
||||||
|
var promise;
|
||||||
|
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
promise = document.exitFullscreen();
|
||||||
|
} else if (document.webkitExitFullscreen) {
|
||||||
|
promise = document.webkitExitFullscreen();
|
||||||
|
} else if (document.msExitFullscreen) {
|
||||||
|
promise = document.msExitFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promise && promise.catch) {
|
||||||
|
promise.catch(function (err) {
|
||||||
|
console.warn("Exit fullscreen failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncFullscreenToggle: function () {
|
||||||
|
var active = trmnl.isFullscreenActive();
|
||||||
|
trmnl.ui.fullscreenToggle.checked = active;
|
||||||
|
},
|
||||||
|
|
||||||
|
isWakeLockSupported: function () {
|
||||||
|
return (
|
||||||
|
window.isSecureContext &&
|
||||||
|
navigator.wakeLock &&
|
||||||
|
typeof navigator.wakeLock.request === "function"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
acquireWakeLock: function () {
|
||||||
|
|
||||||
|
if (!trmnl.isWakeLockSupported()) {
|
||||||
|
return {
|
||||||
|
then: function () { return this; },
|
||||||
|
catch: function () { return this; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trmnl.wakeLock) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigator.wakeLock.request("screen")
|
||||||
|
.then(function (sentinel) {
|
||||||
|
|
||||||
|
trmnl.wakeLock = sentinel;
|
||||||
|
|
||||||
|
sentinel.addEventListener("release", function () {
|
||||||
|
trmnl.wakeLock = null;
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Wake Lock attivo");
|
||||||
|
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn("Wake Lock failed:", err);
|
||||||
|
trmnl.wakeLock = null;
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
releaseWakeLock: function () {
|
||||||
|
|
||||||
|
if (!trmnl.wakeLock) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return trmnl.wakeLock.release()
|
||||||
|
.then(function () {
|
||||||
|
trmnl.wakeLock = null;
|
||||||
|
console.log("Wake Lock rilasciato");
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.warn("Release failed:", err);
|
||||||
|
trmnl.wakeLock = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
toggleWakeLock: function () {
|
||||||
|
if (!trmnl.isWakeLockSupported()) return;
|
||||||
|
if (trmnl.wakeLock) {
|
||||||
|
trmnl.releaseWakeLock().then(function () {
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
trmnl.acquireWakeLock().then(function () {
|
||||||
|
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
document.body.classList.remove("dark", "night")
|
||||||
|
if (displayMode) {
|
||||||
|
document.body.classList.add(displayMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = {
|
||||||
|
"Access-Token": apiKey
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var msg = xhr.statusText
|
||||||
|
if (xhr.status == 404) {
|
||||||
|
msg = "Maybe wrong API key";
|
||||||
|
}
|
||||||
|
trmnl.showStatus(
|
||||||
|
"Failed to fetch screen: " + xhr.status + " " + msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (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.displayModeSelect = document.getElementById("display_mode");
|
||||||
|
trmnl.ui.fullscreenToggle = document.getElementById("fullscreenToggle");
|
||||||
|
trmnl.ui.wakeLockToggle = document.getElementById("wakeLockToggle");
|
||||||
|
trmnl.ui.setup = document.getElementById("setup");
|
||||||
|
|
||||||
|
// Sync fullscreen state
|
||||||
|
document.addEventListener("fullscreenchange", trmnl.syncFullscreenToggle);
|
||||||
|
document.addEventListener("webkitfullscreenchange", trmnl.syncFullscreenToggle);
|
||||||
|
document.addEventListener("msfullscreenchange", trmnl.syncFullscreenToggle);
|
||||||
|
|
||||||
|
// Fullscreen toggle
|
||||||
|
if (!trmnl.isFullscreenSupported()) {
|
||||||
|
trmnl.ui.fullscreenToggle.disabled = true;
|
||||||
|
trmnl.ui.fullscreenToggle.parentElement.style.opacity = "0.5";
|
||||||
|
trmnl.ui.fullscreenToggle.parentElement.style.cursor = "not-allowed";
|
||||||
|
} else {
|
||||||
|
trmnl.ui.fullscreenToggle.addEventListener("change", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (e.target.checked) {
|
||||||
|
trmnl.enterFullscreen();
|
||||||
|
} else {
|
||||||
|
trmnl.exitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var wakeLockHint = document.getElementById("wakeLockHint");
|
||||||
|
|
||||||
|
// Wake Lock toggle
|
||||||
|
if (trmnl.isWakeLockSupported()) {
|
||||||
|
|
||||||
|
trmnl.ui.wakeLockToggle.disabled = false;
|
||||||
|
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "1";
|
||||||
|
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "pointer";
|
||||||
|
|
||||||
|
if (wakeLockHint) wakeLockHint.style.display = "none";
|
||||||
|
|
||||||
|
trmnl.ui.wakeLockToggle.addEventListener("change", function () {
|
||||||
|
trmnl.toggleWakeLock();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", function () {
|
||||||
|
if (
|
||||||
|
document.visibilityState === "visible" &&
|
||||||
|
trmnl.ui.wakeLockToggle.checked
|
||||||
|
) {
|
||||||
|
trmnl.acquireWakeLock();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// unsupported (HTTP or old browser)
|
||||||
|
trmnl.ui.wakeLockToggle.disabled = true;
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "0.5";
|
||||||
|
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "not-allowed";
|
||||||
|
|
||||||
|
if (!window.isSecureContext && wakeLockHint) {
|
||||||
|
wakeLockHint.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// get settings from localstorage
|
||||||
|
var settings = trmnl.getSettings();
|
||||||
|
|
||||||
|
// show setup form if missing apikey
|
||||||
|
if (!settings || !settings.api_key) {
|
||||||
|
trmnl.showSetupForm();
|
||||||
|
} else {
|
||||||
|
trmnl.fetchDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto fullscreen at first click/touch if option enabled
|
||||||
|
if (settings.fullscreen && trmnl.isFullscreenSupported()) {
|
||||||
|
var activateFullscreenOnce = function () {
|
||||||
|
trmnl.enterFullscreen();
|
||||||
|
document.removeEventListener("click", activateFullscreenOnce);
|
||||||
|
document.removeEventListener("touchstart", activateFullscreenOnce);
|
||||||
|
};
|
||||||
|
document.addEventListener("click", activateFullscreenOnce, { once: true });
|
||||||
|
document.addEventListener("touchstart", activateFullscreenOnce, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto Wake Lock at first click/touch if option enabled
|
||||||
|
if (settings.wake_lock && trmnl.isWakeLockSupported()) {
|
||||||
|
var acquireWakeLockOnce = function () {
|
||||||
|
trmnl.acquireWakeLock().then(function () {
|
||||||
|
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
|
||||||
|
}).catch(function (err) {
|
||||||
|
console.warn("Wake Lock request failed:", err);
|
||||||
|
trmnl.ui.wakeLockToggle.checked = false;
|
||||||
|
});
|
||||||
|
document.removeEventListener("click", acquireWakeLockOnce);
|
||||||
|
document.removeEventListener("touchstart", acquireWakeLockOnce);
|
||||||
|
};
|
||||||
|
document.addEventListener("click", acquireWakeLockOnce, { once: true });
|
||||||
|
document.addEventListener("touchstart", acquireWakeLockOnce, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
trmnl.syncFullscreenToggle();
|
||||||
|
} //init end
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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-secondary {
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row label,
|
||||||
|
.setting-row .toggle-label {
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row select,
|
||||||
|
.setting-row .switch {
|
||||||
|
width: auto;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
inset: 0;
|
||||||
|
background-color: #ccc;
|
||||||
|
border-radius: 28px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
height: 22px;
|
||||||
|
width: 22px;
|
||||||
|
left: 3px;
|
||||||
|
top: 3px;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked+.slider {
|
||||||
|
background-color: #f54900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:checked+.slider::before {
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:disabled+.slider {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch input:disabled+.slider::before {
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-select-small {
|
||||||
|
width: 6em;
|
||||||
|
font-size: 1em;
|
||||||
|
padding: 0.4em 0.5em;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 0.5em;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin: 0;
|
||||||
|
cursor: default;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
font-size: 0.75em;
|
||||||
|
color: #f41414;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback for iOS 9 */
|
||||||
|
@media screen and (max-width: 1024px) and (-webkit-min-device-pixel-ratio: 1) {
|
||||||
|
.setting-row {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row label,
|
||||||
|
.setting-row .toggle-label {
|
||||||
|
float: left;
|
||||||
|
line-height: 28px;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row select,
|
||||||
|
.setting-row .switch {
|
||||||
|
float: right;
|
||||||
|
width: auto;
|
||||||
|
min-width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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="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>
|
||||||
|
|
||||||
|
<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 class="setting-row">
|
||||||
|
<label for="display_mode">Display Mode</label>
|
||||||
|
<select id="display_mode" name="display_mode" class="form-select-small">
|
||||||
|
<option value="" selected>Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
<option value="night">Night</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="setting-row">
|
||||||
|
<span class="toggle-label">Fullscreen</span>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="fullscreenToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="setting-row">
|
||||||
|
<div>
|
||||||
|
<span class="toggle-label">Screen Wake Lock</span>
|
||||||
|
<div id="wakeLockHint" class="setting-hint" style="display:none;">
|
||||||
|
Require HTTPS
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="wakeLockToggle">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
|
</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 btn-secondary" onclick="trmnl.fetchDisplay()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
7
public/mirror/manifest.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "TRMNL BYOS Laravel Mirror",
|
||||||
|
"short_name": "TRMNL BYOS",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff"
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,10 @@
|
||||||
@apply !mb-0 !leading-tight;
|
@apply !mb-0 !leading-tight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-flux-description] a {
|
||||||
|
@apply text-accent underline hover:opacity-80;
|
||||||
|
}
|
||||||
|
|
||||||
input:focus[data-flux-control],
|
input:focus[data-flux-control],
|
||||||
textarea:focus[data-flux-control],
|
textarea:focus[data-flux-control],
|
||||||
select:focus[data-flux-control] {
|
select:focus[data-flux-control] {
|
||||||
|
|
@ -68,3 +72,39 @@ select:focus[data-flux-control] {
|
||||||
/* \[:where(&)\]:size-4 {
|
/* \[:where(&)\]:size-4 {
|
||||||
@apply size-4;
|
@apply size-4;
|
||||||
} */
|
} */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* standard container for app */
|
||||||
|
.styled-container,
|
||||||
|
.tab-button {
|
||||||
|
@apply rounded-xl border bg-white dark:bg-stone-950 dark:border-zinc-700 text-stone-800 shadow-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
@apply flex items-center gap-2 px-4 py-2 text-sm font-medium;
|
||||||
|
@apply rounded-b-none shadow-none bg-inherit;
|
||||||
|
|
||||||
|
/* This makes the button sit slightly over the box border */
|
||||||
|
margin-bottom: -1px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.is-active {
|
||||||
|
@apply text-zinc-700 dark:text-zinc-300;
|
||||||
|
@apply border-b-white dark:border-b-zinc-800;
|
||||||
|
|
||||||
|
/* Z-index 10 ensures the bottom border of the tab hides the top border of the box */
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:not(.is-active) {
|
||||||
|
@apply text-zinc-500 border-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button:not(.is-active):hover {
|
||||||
|
@apply text-zinc-700 dark:text-zinc-300;
|
||||||
|
@apply border-zinc-300 dark:border-zinc-700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||||
import { ViewPlugin } from '@codemirror/view';
|
import { ViewPlugin } from '@codemirror/view';
|
||||||
import { indentWithTab } from '@codemirror/commands';
|
import { indentWithTab, selectAll } from '@codemirror/commands';
|
||||||
import { foldGutter, foldKeymap } from '@codemirror/language';
|
import { foldGutter, foldKeymap } from '@codemirror/language';
|
||||||
import { history, historyKeymap } from '@codemirror/commands';
|
import { history, historyKeymap } from '@codemirror/commands';
|
||||||
|
import { searchKeymap } from '@codemirror/search';
|
||||||
import { html } from '@codemirror/lang-html';
|
import { html } from '@codemirror/lang-html';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
import { javascript } from '@codemirror/lang-javascript';
|
||||||
import { json } from '@codemirror/lang-json';
|
import { json } from '@codemirror/lang-json';
|
||||||
import { css } from '@codemirror/lang-css';
|
import { css } from '@codemirror/lang-css';
|
||||||
import { liquid } from '@codemirror/lang-liquid';
|
import { liquid } from '@codemirror/lang-liquid';
|
||||||
|
import { yaml } from '@codemirror/lang-yaml';
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
import { oneDark } from '@codemirror/theme-one-dark';
|
||||||
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
|
import { githubLight } from '@fsegurai/codemirror-theme-github-light';
|
||||||
|
|
||||||
|
|
@ -19,6 +21,8 @@ const LANGUAGE_MAP = {
|
||||||
'css': css,
|
'css': css,
|
||||||
'liquid': liquid,
|
'liquid': liquid,
|
||||||
'html': html,
|
'html': html,
|
||||||
|
'yaml': yaml,
|
||||||
|
'yml': yaml,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Theme support mapping
|
// Theme support mapping
|
||||||
|
|
@ -154,7 +158,16 @@ export function createCodeMirror(element, options = {}) {
|
||||||
createResizePlugin(),
|
createResizePlugin(),
|
||||||
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
...(Array.isArray(languageSupport) ? languageSupport : [languageSupport]),
|
||||||
...themeSupport,
|
...themeSupport,
|
||||||
keymap.of([indentWithTab, ...foldKeymap, ...historyKeymap]),
|
keymap.of([
|
||||||
|
indentWithTab,
|
||||||
|
...foldKeymap,
|
||||||
|
...historyKeymap,
|
||||||
|
...searchKeymap,
|
||||||
|
{
|
||||||
|
key: 'Mod-a',
|
||||||
|
run: selectAll,
|
||||||
|
},
|
||||||
|
]),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
'&': {
|
'&': {
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
|
|
|
||||||
|
|
@ -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 text-gray-600']) }}
|
{{ $attributes->merge(['class' => 'text-sm']) }}
|
||||||
>
|
>
|
||||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
'description',
|
'description',
|
||||||
])
|
])
|
||||||
|
|
||||||
<div class="flex w-full flex-col gap-2 text-center">
|
<div class="flex w-full flex-col text-center">
|
||||||
<h1 class="text-xl font-medium dark:text-zinc-200">{{ $title }}</h1>
|
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||||
<p class="text-center text-sm dark:text-zinc-400">{{ $description }}</p>
|
<flux:subheading>{{ $description }}</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
39
resources/views/components/desktop-user-menu.blade.php
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<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>
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
<x-layouts.auth.card>
|
|
||||||
{{ $slot }}
|
|
||||||
</x-layouts.auth.card>
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
<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>
|
|
||||||
23
resources/views/default-screens/error.blade.php
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
@props([
|
||||||
|
'noBleed' => false,
|
||||||
|
'darkMode' => false,
|
||||||
|
'deviceVariant' => 'og',
|
||||||
|
'deviceOrientation' => null,
|
||||||
|
'colorDepth' => '1bit',
|
||||||
|
'scaleLevel' => null,
|
||||||
|
'pluginName' => 'Recipe',
|
||||||
|
])
|
||||||
|
|
||||||
|
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||||
|
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||||
|
scale-level="{{$scaleLevel}}">
|
||||||
|
<x-trmnl::view>
|
||||||
|
<x-trmnl::layout>
|
||||||
|
<x-trmnl::richtext gapSize="large" align="center">
|
||||||
|
<x-trmnl::title>Error on {{ $pluginName }}</x-trmnl::title>
|
||||||
|
<x-trmnl::content>Unable to render content. Please check server logs.</x-trmnl::content>
|
||||||
|
</x-trmnl::richtext>
|
||||||
|
</x-trmnl::layout>
|
||||||
|
<x-trmnl::title-bar/>
|
||||||
|
</x-trmnl::view>
|
||||||
|
</x-trmnl::screen>
|
||||||
|
|
@ -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="pl-3 pr-4">
|
<div class="ps-3 pe-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] pl-7 data-open:block" @if ($expanded === true) data-open @endif>
|
<div class="relative hidden space-y-[2px] ps-7 data-open:block" @if ($expanded === true) data-open @endif>
|
||||||
<div class="absolute inset-y-[3px] left-0 ml-4 w-px bg-zinc-200 dark:bg-white/30"></div>
|
<div class="absolute inset-y-[3px] start-0 ms-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 endif; ?>
|
<?php } ?>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -70,6 +70,14 @@
|
||||||
<flux:menu.radio.group>
|
<flux:menu.radio.group>
|
||||||
<flux:menu.item href="{{ route('settings.preferences') }}" wire:navigate icon="cog">Settings</flux:menu.item>
|
<flux:menu.item href="{{ route('settings.preferences') }}" wire:navigate icon="cog">Settings</flux:menu.item>
|
||||||
<flux:menu.item href="{{ route('settings.support') }}" wire:navigate icon="heart">Support</flux:menu.item>
|
<flux:menu.item href="{{ route('settings.support') }}" wire:navigate icon="heart">Support</flux:menu.item>
|
||||||
|
@if(config('app.version'))
|
||||||
|
<flux:menu.item href="{{ route('settings.update') }}" wire:navigate icon="arrow-down-circle">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Updates</span>
|
||||||
|
<livewire:update-badge />
|
||||||
|
</div>
|
||||||
|
</flux:menu.item>
|
||||||
|
@endif
|
||||||
</flux:menu.radio.group>
|
</flux:menu.radio.group>
|
||||||
|
|
||||||
<flux:menu.separator/>
|
<flux:menu.separator/>
|
||||||
96
resources/views/layouts/app/sidebar.blade.php
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
<!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>
|
||||||
3
resources/views/layouts/auth.blade.php
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<x-layouts::auth.simple :title="$title ?? null">
|
||||||
|
{{ $slot }}
|
||||||
|
</x-layouts::auth.simple>
|
||||||
|
|
@ -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">
|
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||||
<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>
|
||||||
|
|
@ -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">
|
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||||
<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>
|
||||||
|
|
@ -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-r dark:border-neutral-800">
|
<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="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">
|
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
|
||||||
<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="mr-2 h-7 fill-current text-white" />
|
<x-app-logo-icon class="me-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">
|
||||||
<p class="text-lg">“{{ trim($message) }}”</p>
|
<flux:heading size="lg">“{{ trim($message) }}”</flux:heading>
|
||||||
<footer class="text-sm">{{ trim($author) }}</footer>
|
<footer><flux:heading>{{ trim($author) }}</flux:heading></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">
|
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
|
||||||
<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>
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
<?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>
|
|
||||||
|
|
@ -1,20 +1,24 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Services\PluginImportService;
|
use App\Services\PluginImportService;
|
||||||
use Livewire\Attributes\Lazy;
|
|
||||||
use Livewire\Volt\Component;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Livewire\Attributes\Lazy;
|
||||||
|
use Livewire\Component;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
new
|
new
|
||||||
#[Lazy]
|
#[Lazy]
|
||||||
class extends Component {
|
class extends Component
|
||||||
|
{
|
||||||
public array $catalogPlugins = [];
|
public array $catalogPlugins = [];
|
||||||
|
|
||||||
public string $installingPlugin = '';
|
public string $installingPlugin = '';
|
||||||
|
|
||||||
public string $previewingPlugin = '';
|
public string $previewingPlugin = '';
|
||||||
|
|
||||||
public array $previewData = [];
|
public array $previewData = [];
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
|
|
@ -51,7 +55,7 @@ class extends Component {
|
||||||
return collect($catalog)
|
return collect($catalog)
|
||||||
->filter(function ($plugin) use ($currentVersion) {
|
->filter(function ($plugin) use ($currentVersion) {
|
||||||
// Check if Laravel compatibility is true
|
// Check if Laravel compatibility is true
|
||||||
if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,12 +81,14 @@ 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 [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -94,8 +100,9 @@ class extends Component {
|
||||||
|
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||||
|
|
||||||
if (!$plugin || !$plugin['zip_url']) {
|
if (! $plugin || ! $plugin['zip_url']) {
|
||||||
$this->addError('installation', 'Plugin not found or no download URL available.');
|
$this->addError('installation', 'Plugin not found or no download URL available.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,15 +113,16 @@ class extends Component {
|
||||||
$plugin['zip_url'],
|
$plugin['zip_url'],
|
||||||
auth()->user(),
|
auth()->user(),
|
||||||
$plugin['zip_entry_path'] ?? null,
|
$plugin['zip_entry_path'] ?? null,
|
||||||
null,
|
config('services.trmnl.liquid_enabled') ? $plugin['preferred_renderer'] : 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 = '';
|
||||||
}
|
}
|
||||||
|
|
@ -124,32 +132,27 @@ class extends Component {
|
||||||
{
|
{
|
||||||
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
$plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId);
|
||||||
|
|
||||||
if (!$plugin) {
|
if (! $plugin) {
|
||||||
$this->addError('preview', 'Plugin not found.');
|
$this->addError('preview', 'Plugin not found.');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->previewingPlugin = $pluginId;
|
$this->previewingPlugin = $pluginId;
|
||||||
$this->previewData = $plugin;
|
$this->previewData = $plugin;
|
||||||
|
|
||||||
// Store scroll position for restoration later
|
|
||||||
$this->dispatch('store-scroll-position');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function closePreview(): void
|
public function closePreview(): void
|
||||||
{
|
{
|
||||||
$this->previewingPlugin = '';
|
$this->previewingPlugin = '';
|
||||||
$this->previewData = [];
|
$this->previewData = [];
|
||||||
|
|
||||||
// Restore scroll position when returning to catalog
|
|
||||||
$this->dispatch('restore-scroll-position');
|
|
||||||
}
|
}
|
||||||
}; ?>
|
}; ?>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if(empty($catalogPlugins))
|
@if(empty($catalogPlugins))
|
||||||
<div class="text-center py-8">
|
<div class="text-center py-8">
|
||||||
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-gray-400" />
|
<flux:icon name="exclamation-triangle" class="mx-auto h-12 w-12 text-zinc-400" />
|
||||||
<flux:heading class="mt-2">No plugins available</flux:heading>
|
<flux:heading class="mt-2">No plugins available</flux:heading>
|
||||||
<flux:subheading>Catalog is empty</flux:subheading>
|
<flux:subheading>Catalog is empty</flux:subheading>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -160,30 +163,30 @@ class extends Component {
|
||||||
@enderror
|
@enderror
|
||||||
|
|
||||||
@foreach($catalogPlugins as $plugin)
|
@foreach($catalogPlugins as $plugin)
|
||||||
<div class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
<div wire:key="plugin-{{ $plugin['id'] }}" class="bg-white dark:bg-white/10 border border-zinc-200 dark:border-white/10 [:where(&)]:p-6 [:where(&)]:rounded-xl space-y-6">
|
||||||
<div class="flex items-start space-x-4">
|
<div class="flex items-start space-x-4">
|
||||||
@if($plugin['logo_url'])
|
@if($plugin['logo_url'])
|
||||||
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
<img src="{{ $plugin['logo_url'] }}" loading="lazy" alt="{{ $plugin['name'] }}" class="w-12 h-12 rounded-lg object-cover">
|
||||||
@else
|
@else
|
||||||
<div class="w-12 h-12 bg-gray-200 dark:bg-gray-700 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-zinc-200 dark:bg-zinc-700 rounded-lg flex items-center justify-center">
|
||||||
<flux:icon name="puzzle-piece" class="w-6 h-6 text-gray-400" />
|
<flux:icon name="puzzle-piece" class="w-6 h-6 text-zinc-400" />
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">{{ $plugin['name'] }}</h3>
|
<flux:heading size="lg">{{ $plugin['name'] }}</flux:heading>
|
||||||
@if ($plugin['github'])
|
@if ($plugin['github'])
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">by {{ $plugin['github'] }}</p>
|
<flux:text size="sm" class="text-zinc-500 dark:text-zinc-400">by {{ $plugin['github'] }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@if($plugin['license'])
|
@if($plugin['license'])
|
||||||
<flux:badge color="gray" size="sm">{{ $plugin['license'] }}</flux:badge>
|
<flux:badge color="zinc" size="sm">{{ $plugin['license'] }}</flux:badge>
|
||||||
@endif
|
@endif
|
||||||
@if($plugin['repo_url'])
|
@if($plugin['repo_url'])
|
||||||
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
<a href="{{ $plugin['repo_url'] }}" target="_blank" class="text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300">
|
||||||
<flux:icon name="github" class="w-5 h-5" />
|
<flux:icon name="github" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
|
|
@ -191,7 +194,7 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($plugin['description'])
|
@if($plugin['description'])
|
||||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">{{ $plugin['description'] }}</p>
|
<flux:text class="mt-2" size="sm">{{ $plugin['description'] }}</flux:text>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-4 flex items-center space-x-3">
|
<div class="mt-4 flex items-center space-x-3">
|
||||||
|
|
@ -201,14 +204,16 @@ class extends Component {
|
||||||
Install
|
Install
|
||||||
</flux:button>
|
</flux:button>
|
||||||
|
|
||||||
<flux:modal.trigger name="catalog-preview">
|
@if($plugin['screenshot_url'])
|
||||||
<flux:button
|
<flux:modal.trigger name="catalog-preview">
|
||||||
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
<flux:button
|
||||||
variant="subtle"
|
wire:click="previewPlugin('{{ $plugin['id'] }}')"
|
||||||
icon="eye">
|
variant="subtle"
|
||||||
Preview
|
icon="eye">
|
||||||
</flux:button>
|
Preview
|
||||||
</flux:modal.trigger>
|
</flux:button>
|
||||||
|
</flux:modal.trigger>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -236,34 +241,20 @@ class extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@if($previewData['screenshot_url'])
|
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden">
|
<img src="{{ $previewData['screenshot_url'] }}"
|
||||||
<img src="{{ $previewData['screenshot_url'] }}"
|
alt="Preview of {{ $previewData['name'] }}"
|
||||||
alt="Preview of {{ $previewData['name'] }}"
|
class="w-full h-auto max-h-[480px] object-contain">
|
||||||
class="w-full h-auto max-h-[480px] object-contain">
|
</div>
|
||||||
</div>
|
|
||||||
@elseif($previewData['logo_url'])
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<img src="{{ $previewData['logo_url'] }}"
|
|
||||||
alt="{{ $previewData['name'] }} logo"
|
|
||||||
class="mx-auto h-32 w-auto object-contain mb-4">
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">No preview image available</p>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<div class="bg-white dark:bg-zinc-900 rounded-lg overflow-hidden p-8 text-center">
|
|
||||||
<flux:icon name="puzzle-piece" class="mx-auto h-32 w-32 text-gray-400 mb-4" />
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">No preview available</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($previewData['description'])
|
@if($previewData['description'])
|
||||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
<div class="p-4 bg-zinc-50 dark:bg-zinc-800 rounded-lg">
|
||||||
<h4 class="font-medium text-gray-900 dark:text-gray-100 mb-2">Description</h4>
|
<flux:heading size="sm" class="mb-2">Description</flux:heading>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-300">{{ $previewData['description'] }}</p>
|
<flux:text size="sm">{{ $previewData['description'] }}</flux:text>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700 space-x-3">
|
<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:modal.close>
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
wire:click="installPlugin('{{ $previewingPlugin }}')"
|
||||||
|
|
@ -276,54 +267,3 @@ class extends Component {
|
||||||
@endif
|
@endif
|
||||||
</flux:modal>
|
</flux:modal>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@script
|
|
||||||
<script>
|
|
||||||
let catalogScrollPosition = 0;
|
|
||||||
|
|
||||||
$wire.on('store-scroll-position', () => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
catalogScrollPosition = scrollContainer.scrollTop || 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$wire.on('restore-scroll-position', () => {
|
|
||||||
// Small delay to ensure modal is fully rendered
|
|
||||||
setTimeout(() => {
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
const scrollContainer = catalogModal.querySelector('.space-y-4') || catalogModal;
|
|
||||||
scrollContainer.scrollTop = catalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for when the catalog modal is opened and restore scroll position
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const observer = new MutationObserver(function(mutations) {
|
|
||||||
mutations.forEach(function(mutation) {
|
|
||||||
if (mutation.type === 'attributes' && mutation.attributeName === 'data-flux-modal-open') {
|
|
||||||
const target = mutation.target;
|
|
||||||
if (target.getAttribute('data-flux-modal') === 'import-from-catalog' &&
|
|
||||||
target.getAttribute('data-flux-modal-open') === 'true') {
|
|
||||||
// Modal was opened, restore scroll position
|
|
||||||
setTimeout(() => {
|
|
||||||
const scrollContainer = target.querySelector('.space-y-4') || target;
|
|
||||||
if (catalogScrollPosition > 0) {
|
|
||||||
scrollContainer.scrollTop = catalogScrollPosition;
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogModal = document.querySelector('[data-flux-modal="import-from-catalog"]');
|
|
||||||
if (catalogModal) {
|
|
||||||
observer.observe(catalogModal, { attributes: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@endscript
|
|
||||||
|
|
|
||||||