feat/docker-compose-update (#18)

Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/18
This commit is contained in:
Jon Brookes 2026-02-08 18:04:18 +01:00
parent fd43495e2d
commit 1a22fd156d
70 changed files with 1068 additions and 745 deletions

View file

@ -1,6 +1,7 @@
# Ignore .env files # Ignore .env files
.env .env
.env.* .env.*
.envrc
# Ignore node_modules # Ignore node_modules
node_modules/ node_modules/
@ -25,4 +26,7 @@ Thumbs.db
/storage/framework/cache/* /storage/framework/cache/*
/storage/framework/sessions/* /storage/framework/sessions/*
/storage/framework/views/* /storage/framework/views/*
/storage/logs/* /storage/logs/*
# Ignore database files if in development
database/database.sqlite

View file

@ -66,5 +66,7 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
MEDIA_DISK=s3
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View file

@ -499,3 +499,12 @@ Fortify is a headless authentication backend that provides authentication routes
- `Features::updatePasswords()` to let users change their passwords. - `Features::updatePasswords()` to let users change their passwords.
- `Features::resetPasswords()` for password reset via email. - `Features::resetPasswords()` for password reset via email.
</laravel-boost-guidelines> </laravel-boost-guidelines>
=== docker/core rules ===
## Over-Engineering & Bloat
- Do not add unnecessary boilerplate or "nice-to-have" features.
- Only implement what solves the immediate problem.
- Ask before adding optional infrastructure or configuration sections.
- If a system worked before without something, don't add it "just in case".
- Minimize configuration, complexity, and dependencies.

4
.gitignore vendored
View file

@ -9,6 +9,7 @@
.env .env
.env.backup .env.backup
.env.production .env.production
.env.dev
.phpactor.json .phpactor.json
.phpunit.result.cache .phpunit.result.cache
Homestead.json Homestead.json
@ -28,3 +29,6 @@ log*.txt
.envrc .envrc
database/backups database/backups
*backup.tar.gz *backup.tar.gz
public/css
public/js
notes

View file

@ -1,6 +1,6 @@
when: when:
- event: push - event: push
branch: feat/docker-compose-update branch: dev
steps: steps:
build-local: build-local:
image: docker:24-dind image: docker:24-dind
@ -14,10 +14,10 @@ steps:
- docker pull quay.io/marshyon/share-lt:latest || true - docker pull quay.io/marshyon/share-lt:latest || true
- echo "Building image for testing (amd64 only for CI compatibility)..." - echo "Building image for testing (amd64 only for CI compatibility)..."
- docker build --platform linux/amd64 --cache-from=quay.io/marshyon/share-lt:latest -t share-lt:test . - docker build --platform linux/amd64 --cache-from=quay.io/marshyon/share-lt:latest -t share-lt:test .
- echo "Tagging test image as quay.io/marshyon/share-lt:v0.0.2..." - echo "Tagging test image as quay.io/marshyon/share-lt:v0.0.5..."
- docker tag share-lt:test quay.io/marshyon/share-lt:v0.0.2 - docker tag share-lt:test quay.io/marshyon/share-lt:v0.0.5
- echo "Generating SBOM..." - echo "Generating SBOM..."
- docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:latest scan quay.io/marshyon/share-lt:v0.0.2 -o cyclonedx-json > sbom.json - docker run --rm -v /var/run/docker.sock:/var/run/docker.sock anchore/syft:latest scan quay.io/marshyon/share-lt:v0.0.5 -o cyclonedx-json > sbom.json
scan-vulnerabilities: scan-vulnerabilities:
image: aquasec/trivy:0.67.2 image: aquasec/trivy:0.67.2
volumes: volumes:
@ -41,7 +41,7 @@ steps:
repo: quay.io/marshyon/share-lt repo: quay.io/marshyon/share-lt
platforms: linux/amd64 platforms: linux/amd64
tags: tags:
- v0.0.2 - v0.0.5
- latest - latest
username: username:
from_secret: QUAY_USERNAME from_secret: QUAY_USERNAME
@ -57,5 +57,6 @@ steps:
COSIGN_REGISTRY_PASSWORD: COSIGN_REGISTRY_PASSWORD:
from_secret: QUAY_PASSWORD from_secret: QUAY_PASSWORD
commands: commands:
- cosign attach sbom --sbom sbom.json quay.io/marshyon/share-lt:v0.0.2 || echo "SBOM attach failed" - cosign attach sbom --sbom sbom.json quay.io/marshyon/share-lt:v0.0.5 || echo "SBOM attach failed"
- echo "Done - trivy report saved to workspace for manual review" - echo "Done - trivy report saved to workspace for manual review"

View file

@ -1,12 +1,8 @@
FROM php:8.5-fpm-alpine FROM php:8.4-fpm-alpine3.23
# Copy composer.lock and composer.json ENV APP_ENV=production
COPY composer.lock composer.json /var/www/ ENV APP_DEBUG=false
# Set working directory
WORKDIR /var/www WORKDIR /var/www
# # Install dependencies
RUN apk update && apk add --no-cache \ RUN apk update && apk add --no-cache \
build-base \ build-base \
libpng-dev \ libpng-dev \
@ -20,70 +16,86 @@ RUN apk update && apk add --no-cache \
curl \ curl \
libzip-dev \ libzip-dev \
oniguruma-dev \ oniguruma-dev \
icu-dev \
sqlite \
sqlite-dev \
nodejs \ nodejs \
npm npm \
icu-dev \
sqlite-dev \
sqlite-libs \
nginx \
supervisor \
su-exec \
tini \
unzip \
bash \
jq \
&& rm -rf /var/cache/apk/*
RUN curl -sSL https://github.com/nats-io/natscli/releases/download/v0.3.1/nats-0.3.1-linux-amd64.zip -o /tmp/nats.zip \
&& unzip /tmp/nats.zip -d /tmp/nats \
&& mv /tmp/nats/nats-0.3.1-linux-amd64/nats /usr/local/bin/nats \
&& chmod +x /usr/local/bin/nats \
&& rm -rf /tmp/nats /tmp/nats.zip
# # Clear cache
RUN rm -rf /var/cache/apk/* RUN rm -rf /var/cache/apk/*
RUN docker-php-ext-install mbstring zip exif pcntl intl gd pdo pdo_sqlite bcmath
# # Install extensions
RUN docker-php-ext-install mbstring zip exif pcntl intl pdo_sqlite
RUN docker-php-ext-install gd
# # Install composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# Add user for laravel application # Copy entrypoint script
RUN addgroup -g 1000 www COPY cmd/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN adduser -u 1000 -G www -s /bin/sh -D www RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# # Copy existing application directory contents # Copy supervisord configuration
COPY . /var/www COPY ./docker/supervisord.conf /etc/supervisord.conf
RUN mkdir -p /var/log/supervisor \
&& mkdir -p /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx \
&& chown -R root:root /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx
# # Copy existing application directory permissions # Create www user and add to www-data group
COPY --chown=www-data:www-data . /var/www RUN adduser -u 1000 -G www-data -s /bin/sh -D www
# # Install PHP dependencies # Configure PHP-FPM to run as www user
RUN composer install --no-dev --optimize-autoloader RUN sed -i 's/user = www-data/user = www/g' /usr/local/etc/php-fpm.d/www.conf
# Install Node.js dependencies # Remove the semicolon to uncomment the listen directive
RUN npm install RUN sed -i 's/;listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Build assets # Ensure the worker running the code is correct (usually www-data or nginx)
RUN npm run build RUN sed -i 's/;listen.owner = www-data/listen.owner = www/' /usr/local/etc/php-fpm.d/www.conf
RUN sed -i 's/;listen.group = www-data/listen.group = www-data/' /usr/local/etc/php-fpm.d/www.conf
# Change ownership of /var/www to www-data # Update nginx.conf to use 'www' user instead of 'nginx'
RUN chmod -R u+rw /var/www && chown -R www:www /var/www RUN sed -i 's/user nginx;/user www;/' /etc/nginx/nginx.conf
# # RUN php artisan optimize # Remove user and group directives from nginx and php-fpm configs to avoid conflicts
# # removed as it calls all 4 commands below and fails du to RUN sed -i '/^user /d' /etc/nginx/nginx.conf
# # multi-site configuration RUN sed -i '/^user = /d' /usr/local/etc/php-fpm.d/www.conf
RUN sed -i '/^group = /d' /usr/local/etc/php-fpm.d/www.conf
# # Clear any cached configurations # Set permissions for nginx directories
RUN php artisan config:clear RUN mkdir -p /var/lib/nginx/tmp/client_body /var/log/nginx \
# RUN php artisan cache:clear && chown -R www:www-data /var/lib/nginx /var/log/nginx \
RUN php artisan route:clear && chmod -R 755 /var/lib/nginx /var/log/nginx \
RUN php artisan view:clear && touch /run/nginx/nginx.pid \
&& chown www:www-data /run/nginx/nginx.pid
# # Build optimizations # Copy application code (includes database/migrations/) and excluding
RUN php artisan config:cache # files in .dockerignore
RUN php artisan event:cache COPY --chown=www:www-data . /var/www
RUN php artisan view:cache RUN chown -R www:www-data /var/www
RUN chown -R www:www-data /var/log/supervisor
# # RUN php artisan route:cache # Switch to www user
# # Run Laravel artisan command
RUN php artisan storage:link
# # RUN composer install --optimize-autoloader --no-dev
# # Change current user to www
USER www USER www
# # Expose port 9000 and start php-fpm server # Install app dependencies
# EXPOSE 9000 RUN composer install --optimize-autoloader --no-dev
RUN npm ci
RUN npm run build
# run laravel cache optimization
RUN php artisan optimize
EXPOSE 8889
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]

View file

@ -1,82 +0,0 @@
FROM php:8.4-fpm-alpine3.23
ENV APP_ENV=production
ENV APP_DEBUG=false
WORKDIR /var/www
RUN apk update && apk add --no-cache \
build-base \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
zip \
jpegoptim optipng pngquant gifsicle \
vim \
unzip \
git \
curl \
libzip-dev \
oniguruma-dev \
nodejs \
npm \
icu-dev \
sqlite-dev \
sqlite-libs \
nginx \
supervisor \
su-exec \
tini
RUN rm -rf /var/cache/apk/*
RUN docker-php-ext-install mbstring zip exif pcntl intl gd pdo pdo_sqlite bcmath
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
# RUN addgroup -g 1000 www
# RUN adduser -u 1000 -G www -s /bin/sh -D www
# # Configure PHP-FPM to run as www user
# RUN sed -i 's/user = www-data/user = www/g' /usr/local/etc/php-fpm.d/www.conf && \
# sed -i 's/group = www-data/group = www/g' /usr/local/etc/php-fpm.d/www.conf
# Copy application code (includes database/migrations/)
COPY . /var/www
# DEBUG - SHOW ME WHAT WAS COPIED
# RUN echo "===== CONTENTS OF /var/www/database =====" && ls -la /var/www/database/
# RUN echo "===== CONTENTS OF /var/www/database/migrations =====" && ls -la /var/www/database/migrations/
# Install dependencies
RUN composer install --optimize-autoloader --no-dev
RUN npm install
RUN npm run build
# Copy entrypoint script
COPY cmd/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
COPY ./docker/supervisord.conf /etc/supervisord.conf
RUN mkdir -p /var/log/supervisor \
&& mkdir -p /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx \
&& chown -R root:root /run/nginx /var/cache/nginx /var/lib/nginx /var/tmp/nginx
# Test nginx config at build time
RUN nginx -t
# keep running as root so supervisord starts nginx/php-fpm as root (nginx needs root for master process)
# we will use su-exec in entrypoint to run maintenance steps as www, preserving previous behaviour
EXPOSE 8889
# Keep entrypoint script as before; entrypoint runs startup tasks then supervisord becomes PID 1
# ENTRYPOINT ["docker-entrypoint.sh"]
# CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]

View file

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Assets;
use App\Filament\Resources\Assets\Pages\CreateAsset;
use App\Filament\Resources\Assets\Pages\EditAsset;
use App\Filament\Resources\Assets\Pages\ListAssets;
use App\Filament\Resources\Assets\Schemas\AssetForm;
use App\Filament\Resources\Assets\Tables\AssetsTable;
use App\Models\Asset;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class AssetResource extends Resource
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $model = Asset::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::ArrowUpTray;
public static function form(Schema $schema): Schema
{
return AssetForm::configure($schema);
}
public static function table(Table $table): Table
{
return AssetsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListAssets::route('/'),
'create' => CreateAsset::route('/create'),
'edit' => EditAsset::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAsset extends CreateRecord
{
protected static string $resource = AssetResource::class;
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditAsset extends EditRecord
{
protected static string $resource = AssetResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Assets\Pages;
use App\Filament\Resources\Assets\AssetResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListAssets extends ListRecords
{
protected static string $resource = AssetResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\Assets\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Schemas\Schema;
class AssetForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('alt_text'),
SpatieMediaLibraryFileUpload::make('image')
->collection('default')
->image()
->disk('s3')
->visibility('public')
->label('Upload Image'),
]);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Assets\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class AssetsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('alt_text')
->searchable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -52,6 +52,7 @@ class EntryForm
->visible(fn($get) => $get('type') === 'article') ->visible(fn($get) => $get('type') === 'article')
->columnSpanFull(), ->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('featured_image') SpatieMediaLibraryFileUpload::make('featured_image')
->multiple() // <- force array handling for Filament v4 bug
->visible( ->visible(
fn($get) => fn($get) =>
$get('type') === 'article' || $get('type') === 'image' $get('type') === 'article' || $get('type') === 'image'
@ -64,9 +65,63 @@ class EntryForm
->columnSpanFull() ->columnSpanFull()
->dehydrated(false) ->dehydrated(false)
->saveUploadedFileUsing(function ($file, $record) { ->saveUploadedFileUsing(function ($file, $record) {
$diskName = config('media-library.disk_name', 'public'); if (is_array($file)) {
$file = reset($file);
}
if (config('app.env') === 'local') { // Validate upload object early
if (
! is_object($file) ||
! (method_exists($file, 'getRealPath') || method_exists($file, 'getPathname') || method_exists($file, 'getStream') || method_exists($file, 'store'))
) {
Log::error('Invalid upload object', ['type' => gettype($file)]);
throw new \Exception('Invalid upload object provided to saveUploadedFileUsing');
}
// Use safe variables for further calls
$realPath = method_exists($file, 'getRealPath') ? $file->getRealPath() : null;
$exists = $realPath ? file_exists($realPath) : false;
$name = method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : null;
Log::info('TemporaryUploadedFile Debug', [
'path' => $file->getRealPath(),
'exists' => file_exists($file->getRealPath()),
'name' => $file->getClientOriginalName(),
'temp_dir' => sys_get_temp_dir(),
'disk_root' => config('filesystems.disks.local.root'),
'is_readable' => is_readable($file->getRealPath()),
'is_writable' => is_writable($file->getRealPath()),
]);
// Additional debug: Check if the file is being moved to livewire-tmp
$livewireTmpPath = storage_path('framework/livewire-tmp');
Log::info('Livewire Temp Directory Debug', [
'livewire_tmp_path' => $livewireTmpPath,
'exists' => file_exists($livewireTmpPath),
'is_writable' => is_writable($livewireTmpPath),
]);
// Check if the file is being moved
$tempFilePath = $file->getRealPath();
$newFilePath = $livewireTmpPath . '/' . $file->getClientOriginalName();
if (file_exists($tempFilePath)) {
Log::info('File exists in temp directory', ['temp_file_path' => $tempFilePath]);
} else {
Log::error('File does not exist in temp directory', ['temp_file_path' => $tempFilePath]);
}
// $diskName = config('media-library.disk_name', 'public');
$diskName = config('media-library.disk_name');
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Upload Debug', [ Log::info('Featured Image Upload Debug', [
'disk' => $diskName, 'disk' => $diskName,
'file_name' => $file->getClientOriginalName(), 'file_name' => $file->getClientOriginalName(),
@ -97,7 +152,7 @@ class EntryForm
$disk->put($testFile, 'test content'); $disk->put($testFile, 'test content');
$disk->delete($testFile); $disk->delete($testFile);
if (config('app.env') === 'local') { if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('S3 connectivity test passed'); Log::info('S3 connectivity test passed');
} }
} }
@ -112,12 +167,114 @@ class EntryForm
$encodedName = base64_encode($originalName); $encodedName = base64_encode($originalName);
$secureFileName = Str::random(32) . '-meta' . $encodedName . '-.' . $extension; $secureFileName = Str::random(32) . '-meta' . $encodedName . '-.' . $extension;
$media = $record->addMedia($file->getRealPath())
->usingName($baseName) // Keep original name for display/search
->usingFileName($secureFileName) // Use secure filename for storage
// Resolve possibly-relative Livewire temp path + safe fallbacks
$realPath = $realPath ?: (method_exists($file, 'getRealPath') ? $file->getRealPath() : null);
$candidates = [];
if ($realPath && str_starts_with($realPath, '/')) {
$candidates[] = $realPath;
} else {
$candidates[] = sys_get_temp_dir() . '/' . ltrim((string)$realPath, '/');
$candidates[] = storage_path('framework/' . ltrim((string)$realPath, '/'));
$candidates[] = storage_path(ltrim((string)$realPath, '/'));
$candidates[] = base_path(ltrim((string)$realPath, '/'));
}
if ($realPath) {
$candidates[] = storage_path('framework/livewire-tmp/' . basename($realPath));
$candidates[] = sys_get_temp_dir() . '/' . basename($realPath);
}
// 1) Try storing to local disk (creates an absolute path we control)
$stored = null;
if (method_exists($file, 'store')) {
try {
$stored = $file->store('livewire-temp', 'local'); // storage/app/livewire-temp/...
if ($stored && \Storage::disk('local')->exists($stored)) {
$resolved = \Storage::disk('local')->path($stored);
}
} catch (\Throwable $e) {
Log::debug('store() fallback failed', ['err' => $e->getMessage()]);
}
}
// 2) If not resolved, check candidates
if (! isset($resolved)) {
foreach ($candidates as $p) {
if ($p && file_exists($p)) {
$resolved = $p;
break;
}
}
}
// 3) If still not resolved, try stream -> temp file copy
$is_tmp_copy = false;
if (! isset($resolved)) {
try {
$stream = null;
if (method_exists($file, 'getStream')) {
$stream = $file->getStream();
} elseif (method_exists($file, 'getRealPath') && is_readable($file->getRealPath())) {
$stream = fopen($file->getRealPath(), 'r');
}
if ($stream) {
$tmpPath = tempnam(sys_get_temp_dir(), 'filament-upload-');
$out = fopen($tmpPath, 'w');
stream_copy_to_stream($stream, $out);
fclose($out);
if (is_resource($stream)) {
@fclose($stream);
}
$resolved = $tmpPath;
$is_tmp_copy = true;
}
} catch (\Throwable $e) {
Log::debug('stream fallback failed', ['err' => $e->getMessage()]);
}
}
// 4) Still nothing -> error
if (empty($resolved)) {
Log::error('Featured Image Upload: could not resolve temp path', [
'original' => $realPath,
'checked_candidates' => $candidates,
'stored' => $stored,
]);
throw new \Exception("File `{$realPath}` does not exist");
}
// 5) Use resolved absolute path
$media = $record->addMedia($resolved)
->usingName($baseName)
->usingFileName($secureFileName)
->toMediaCollection('featured-image', $diskName); ->toMediaCollection('featured-image', $diskName);
if (config('app.env') === 'local') { // 6) Cleanup short-lived artifacts
if (! empty($is_tmp_copy) && file_exists($resolved)) {
@unlink($resolved);
}
if (! empty($stored) && \Storage::disk('local')->exists($stored)) {
\Storage::disk('local')->delete($stored);
}
Log::info('Featured image resolved', ['resolved' => $resolved, 'media_id' => $media->id ?? null]);
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Upload Success', [ Log::info('Featured Image Upload Success', [
'media_id' => $media->id, 'media_id' => $media->id,
'media_url' => $media->getUrl(), 'media_url' => $media->getUrl(),
@ -189,7 +346,7 @@ class EntryForm
$record = $component->getRecord(); $record = $component->getRecord();
$diskName = config('media-library.disk_name', 'public'); $diskName = config('media-library.disk_name', 'public');
if (config('app.env') === 'local') { if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Action Debug', [ Log::info('Featured Image Picker Action Debug', [
'disk' => $diskName, 'disk' => $diskName,
'record_id' => $record?->id, 'record_id' => $record?->id,
@ -223,7 +380,7 @@ class EntryForm
try { try {
// For S3, we need to handle file copying differently // For S3, we need to handle file copying differently
if ($sourceMedia->disk === 's3') { if ($sourceMedia->disk === 's3') {
if (config('app.env') === 'local') { if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Copying S3 media to new collection', [ Log::info('Copying S3 media to new collection', [
'source_disk' => $sourceMedia->disk, 'source_disk' => $sourceMedia->disk,
'source_path' => $sourceMedia->getPathRelativeToRoot(), 'source_path' => $sourceMedia->getPathRelativeToRoot(),
@ -267,7 +424,7 @@ class EntryForm
->usingFileName($sourceMedia->file_name) ->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', $diskName); ->toMediaCollection('featured-image', $diskName);
if (config('app.env') === 'local') { if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Success', [ Log::info('Featured Image Picker Success', [
'new_media_id' => $newMedia->id, 'new_media_id' => $newMedia->id,
'new_media_disk' => $newMedia->disk, 'new_media_disk' => $newMedia->disk,

View file

@ -13,7 +13,7 @@ class ListMedia extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
CreateAction::make(), // CreateAction::make(),
]; ];
} }
} }

View file

@ -7,6 +7,8 @@ use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Spatie\MediaLibrary\MediaCollections\Models\Media as SpatieMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media as SpatieMedia;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class MediaForm class MediaForm
{ {
@ -24,6 +26,7 @@ class MediaForm
Hidden::make('disk') Hidden::make('disk')
->default('public'), ->default('public'),
FileUpload::make('file') FileUpload::make('file')
->multiple() // workaround for Filament v4 single-file bug
->label('File') ->label('File')
->imageEditor() ->imageEditor()
->imageEditorAspectRatios([ ->imageEditorAspectRatios([
@ -38,22 +41,50 @@ class MediaForm
->acceptedFileTypes(['image/*', 'application/pdf']) ->acceptedFileTypes(['image/*', 'application/pdf'])
->maxSize(10240) ->maxSize(10240)
->required(fn($context) => $context === 'create') ->required(fn($context) => $context === 'create')
->afterStateHydrated(function (FileUpload $component, $state, $record): void { ->afterStateHydrated(function (FileUpload $component, $state, $record): void {
if (! $record) { Log::info('MediaForm afterStateHydrated invoked', ['record_id' => $record?->id, 'state' => $state]);
return;
try {
if (! $record) {
return;
}
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id . '/' . $media->file_name;
try {
$disk = $media->disk ?? 'public';
if (Storage::disk($disk)->exists($path)) {
$component->state($path);
}
} catch (\Throwable $e) {
Log::error('MediaForm afterStateHydrated storage check failed', [
'err' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'disk' => $media->disk ?? null,
'path' => $path,
]);
}
} catch (\Throwable $e) {
Log::error('MediaForm afterStateHydrated unhandled', [
'err' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'record' => $record?->id,
'state' => $state,
]);
throw $e;
} }
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id . '/' . $media->file_name;
$component->state($path);
}), }),
]); ]);
} }
} }

View file

@ -2,6 +2,7 @@
namespace App\Filament\Resources\Media\Tables; namespace App\Filament\Resources\Media\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@ -19,11 +20,12 @@ class MediaTable
public static function configure(Table $table): Table public static function configure(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn ($query) => $query->where('collection_name', '!=', 'avatars')) ->modifyQueryUsing(fn($query) => $query->where('collection_name', '!=', 'avatars'))
->columns([ ->columns([
ImageColumn::make('url') ImageColumn::make('url')
->label('Preview') ->label('Preview')
->getStateUsing(fn ($record) => ->getStateUsing(
fn($record) =>
// Prefer the stored path produced by Filament's FileUpload (saved in custom_properties), // Prefer the stored path produced by Filament's FileUpload (saved in custom_properties),
// fall back to Spatie's getUrl() when no stored_path exists. // fall back to Spatie's getUrl() when no stored_path exists.
($record->getCustomProperty('stored_path')) ($record->getCustomProperty('stored_path'))
@ -40,7 +42,7 @@ class MediaTable
->badge(), ->badge(),
TextColumn::make('mime_type'), TextColumn::make('mime_type'),
TextColumn::make('size') TextColumn::make('size')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'), ->formatStateUsing(fn($state) => number_format($state / 1024, 2) . ' KB'),
TextColumn::make('created_at') TextColumn::make('created_at')
->dateTime(), ->dateTime(),
]) ])
@ -52,7 +54,7 @@ class MediaTable
]), ]),
]) ])
->recordActions([ ->recordActions([
EditAction::make(), // EditAction::make(),
DeleteAction::make() DeleteAction::make()
->action(function (Media $record) { ->action(function (Media $record) {
// Delete the actual stored file path if we saved one, otherwise fall back to the Spatie path. // Delete the actual stored file path if we saved one, otherwise fall back to the Spatie path.
@ -67,6 +69,13 @@ class MediaTable
}), }),
]) ])
->toolbarActions([ ->toolbarActions([
Action::make('add')
->label('Add')
->icon('heroicon-o-plus')
->url(fn() => url('admin/assets/create'))
->color('primary'),
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make() DeleteBulkAction::make()
->action(function (Collection $records) { ->action(function (Collection $records) {

View file

@ -16,6 +16,9 @@ use Filament\Tables\Table;
class TextWidgetResource extends Resource class TextWidgetResource extends Resource
{ {
protected static bool $shouldRegisterNavigation = false;
protected static ?string $model = TextWidget::class; protected static ?string $model = TextWidget::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText; protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText;

View file

@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Models\Entry;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Console\Exception\RuntimeException;
class ProcessEntryUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $entryId,
public string $action
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
$incoming = [
'id' => $this->entryId,
'action' => $this->action,
];
$jsonData = json_encode($incoming, JSON_THROW_ON_ERROR);
$tempFile = tempnam(sys_get_temp_dir(), 'entry_update_');
file_put_contents($tempFile, $jsonData);
$scriptPath = env('HANDLE_ENTRY_UPDATES_SCRIPT', base_path('cmd/handle_cms_updates.sh'));
try {
// Log what we're about to execute
Log::info("Executing script: {$scriptPath} with action: {$this->action}", [
'entry_id' => $this->entryId,
'temp_file' => $tempFile,
'script_exists' => file_exists($scriptPath),
'script_executable' => is_executable($scriptPath),
]);
$result = Process::run([
'bash',
$scriptPath,
$this->action,
$tempFile
]);
if ($result->failed()) {
$errorDetails = [
'exit_code' => $result->exitCode(),
'stdout' => $result->output(),
'stderr' => $result->errorOutput(),
'command' => ['bash', $scriptPath, $this->action, $tempFile],
'script_path' => $scriptPath,
'script_exists' => file_exists($scriptPath),
'temp_file_exists' => file_exists($tempFile),
'temp_file_contents' => file_exists($tempFile) ? file_get_contents($tempFile) : 'N/A',
];
Log::error("Script execution failed", $errorDetails);
throw new RuntimeException(
"Script execution failed with exit code {$result->exitCode()}. " .
"STDOUT: " . ($result->output() ?: 'empty') . " " .
"STDERR: " . ($result->errorOutput() ?: 'empty')
);
}
Log::info("Script executed successfully", [
'stdout' => $result->output(),
'entry_id' => $this->entryId,
'action' => $this->action,
]);
} finally {
// Clean up temp file
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
}

16
app/Models/Asset.php Normal file
View file

@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Asset extends Model implements HasMedia
{
use InteractsWithMedia;
protected $fillable = [
'alt_text',
];
}

View file

@ -64,7 +64,7 @@ class User extends Authenticatable implements FilamentUser
return Str::of($this->name) return Str::of($this->name)
->explode(' ') ->explode(' ')
->take(2) ->take(2)
->map(fn ($word) => Str::substr($word, 0, 1)) ->map(fn($word) => Str::substr($word, 0, 1))
->implode(''); ->implode('');
} }
@ -73,6 +73,6 @@ class User extends Authenticatable implements FilamentUser
*/ */
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->email === config('app.admin_email'); return $this->email === config('app.admin_email') || $this->role === 'admin';
} }
} }

View file

@ -0,0 +1,50 @@
<?php
namespace App\Observers;
use App\Jobs\ProcessEntryUpdate;
use App\Models\Entry;
use Illuminate\Support\Facades\Log;
class EntryObserver
{
/**
* Handle the Entry "created" event.
*/
public function created(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'created');
}
/**
* Handle the Entry "updated" event.
*/
public function updated(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'updated');
}
/**
* Handle the Entry "deleted" event.
*/
public function deleted(Entry $entry): void
{
ProcessEntryUpdate::dispatch($entry->id, 'deleted');
}
/**
* Handle the Entry "restored" event.
*/
public function restored(Entry $entry): void
{
//
}
/**
* Handle the Entry "force deleted" event.
*/
public function forceDeleted(Entry $entry): void
{
// ProcessEntryUpdate::dispatch($entry, 'force deleted');
}
}

View file

@ -2,7 +2,10 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Entry;
use App\Observers\EntryObserver;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\File;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +22,12 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// // Ensure the livewire-tmp directory exists
$livewireTmpPath = storage_path('framework/livewire-tmp');
if (!File::exists($livewireTmpPath)) {
File::makeDirectory($livewireTmpPath, 0755, true);
}
Entry::observe(EntryObserver::class);
} }
} }

View file

@ -36,10 +36,10 @@ class AdminPanelProvider extends PanelProvider
]) ])
->resources([ ->resources([
\App\Filament\Resources\Entries\EntryResource::class, \App\Filament\Resources\Entries\EntryResource::class,
\App\Filament\Resources\TextWidgets\TextWidgetResource::class,
\App\Filament\Resources\Media\MediaResource::class, \App\Filament\Resources\Media\MediaResource::class,
\App\Filament\Resources\Categroys\CategroyResource::class, \App\Filament\Resources\Categroys\CategroyResource::class,
]) ])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->pages([ ->pages([
Dashboard::class, Dashboard::class,

View file

@ -1,11 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
LARAVEL_CONTAINER_NAME="quay.io/marshyon/share-lt" LARAVEL_CONTAINER_NAME="quay.io/marshyon/share-lt"
CONTAINER_LABEL="0.0.3" CONTAINER_LABEL="v0.0.5"
CACHE="--no-cache" CACHE="--no-cache"
CACHE="" # CACHE=""
docker build \ docker build \
$CACHE \ $CACHE \
-t ${LARAVEL_CONTAINER_NAME}:${CONTAINER_LABEL} \ -t ${LARAVEL_CONTAINER_NAME}:${CONTAINER_LABEL} .
-f Dockerfile.phpfpm .

View file

@ -6,8 +6,7 @@
# tokens need to be created with tinker or similar method # tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/entries' URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \ curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \ -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \ -H "Accept: application/json" \

View file

@ -5,8 +5,7 @@
# granted access to view entries by being given # granted access to view entries by being given
# a token # a token
URL='http://127.0.0.1:8000/api/entries' URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \ curl -s -X GET \
-H "Accept: application/json" \ -H "Accept: application/json" \
$URL $URL

View file

@ -1,36 +1,37 @@
#!/bin/sh #!/bin/sh
set -e set -e
# run_as_www() { # Ensure APP_KEY is set and persisted
# # prefer su-exec (alpine), fallback to runuser if available, otherwise run directly PERSISTED_KEY="/var/www/storage/.app_key"
# if command -v su-exec >/dev/null 2>&1; then
# su-exec www "$@"
# elif command -v runuser >/dev/null 2>&1; then
# runuser -u www -- "$@"
# else
# "$@"
# fi
# }
# Build front-end assets if Vite manifest is missing if [ -z "$APP_KEY" ]; then
if [ ! -f /var/www/public/build/manifest.json ]; then if [ -f "$PERSISTED_KEY" ]; then
echo "Building front-end assets (vite)..." echo "Using persisted APP_KEY from: $PERSISTED_KEY"
run_as_www npm ci export APP_KEY=$(cat "$PERSISTED_KEY")
run_as_www npm run build else
# Generate key, strip "base64:", and save
NEW_KEY=$(php artisan key:generate --show --no-interaction)
echo "Generated new APP_KEY to: $PERSISTED_KEY"
echo "$NEW_KEY" > "$PERSISTED_KEY"
export APP_KEY="$NEW_KEY"
fi
fi fi
# Wait for database directory to be mounted # check to see if /var/www/database/database.sqlite exists
# if [ ! -f /var/www/database/database.sqlite ]; then # if not, run migrations
# echo "Creating database..." if [ ! -f /var/www/database/database.sqlite ]; then
# # create the sqlite file as the www user so ownership matches app files php artisan migrate --force
# run_as_www sh -c 'touch /var/www/database/database.sqlite' fi
# run_as_www php artisan migrate --force
# fi
# Fix storage permissions # check to see if /var/www/public/storage exists
# echo "Fixing storage permissions..." # if not, run storage:link
# chown -R www:www /var/www/storage /var/www/bootstrap/cache if [ ! -d /var/www/public/storage ]; then
# chmod -R 775 /var/www/storage /var/www/bootstrap/cache php artisan storage:link
fi
# Execute the main command php artisan config:clear
# Start supervisord directly
exec "$@" exec "$@"

33
cmd/handle_cms_updates.sh Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
LOG_FILE="/tmp/logfile.log"
# Redirect all output to both stdout and the log file
exec > >(tee -a "$LOG_FILE") 2>&1
# Read the first two command line arguments
ACTION=$1
FILENAME=$2
# Check if the file exists and echo its contents
if [[ -f "$FILENAME" ]]; then
echo "Contents of the file $FILENAME:"
cat "$FILENAME" | jq
else
echo "Error: File $FILENAME does not exist."
fi
echo
# Read and print command line arguments
echo "=============================="
echo "ACTION: $ACTION"
echo "FILENAME: $FILENAME"
echo "=============================="
echo
# Publish message and check return code
if nats pub $NATS_SUBJECT "$(cat "$FILENAME")" --server $NATS_URL --user $NATS_USERNAME --password $NATS_PASSWORD; then
echo "Success: Message published to NATS successfully."
else
echo "Error: Failed to publish message to NATS."
fi

View file

@ -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::create('assets', function (Blueprint $table) {
$table->id();
$table->string('alt_text')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('assets');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('user')->after('email');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

20
docker/php/php.ini Normal file
View file

@ -0,0 +1,20 @@
; PHP Configuration File
; Maximum allowed size for uploaded files.
upload_max_filesize = 10M
; Maximum size of POST data that PHP will accept.
post_max_size = 12M
; Maximum execution time of each script, in seconds.
max_execution_time = 120
; Maximum amount of memory a script may consume.
memory_limit = 256M
; Maximum number of files that can be uploaded via a single request.
max_file_uploads = 20
; File upload temporary directory (optional, defaults to system temp directory).
; upload_tmp_dir = /tmp

View file

@ -24,7 +24,7 @@ stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0 stderr_logfile_maxbytes=0
[program:queue-worker] [program:queue-worker]
command=su-exec www /usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --max-time=3600 command=/usr/local/bin/php /var/www/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true autostart=true
autorestart=true autorestart=true
startretries=3 startretries=3

View file

@ -0,0 +1,17 @@
The ENTRYPOINT script (/usr/local/bin/docker-entrypoint.sh) is executed first.
The CMD (/usr/bin/supervisord -n -c /etc/supervisord.conf) is passed as arguments to the ENTRYPOINT script.
The ENTRYPOINT script can choose to execute the CMD or perform other tasks before running the CMD.
for now, supervisord is used in its current form, that of a python based application
this is ok for now but other options could be considered, such as replacing it with a [go binary port of supervisord](https://github.com/ochinchina/supervisord), using [s6-overlay](https://github.com/just-containers/s6-overlay), [dumb-init](https://github.com/Yelp/dumb-init) or similar process management tools designed for containers.
In the dockerfile, a user directive is used to lower from default root to that of a user that is created - 'www' and which is, for now, given a shell by which it is possible to log into this container for debugging and maintenance.
further on, this can be removed somehow - likely a case for k8s as there are patterns for this that are perhaps not so much for docker alone.

View file

@ -0,0 +1,103 @@
services:
nats:
image: nats:2.9.19-alpine
restart: unless-stopped
#ports:
# - 4222:4222
# - 8222:8222
volumes:
- ./nats/nats-server.conf:/nats-server.conf
- nats-data:/opt/storage
command: ["-c", "/nats-server.conf"]
networks:
- app-network
- nats
# nats-cli:
# image: natsio/nats-box
# container_name: nats-cli
# depends_on:
# - nats
# command: sleep infinity # Keeps container running
# networks:
# - app-network
# - nats
app:
image: quay.io/marshyon/share-lt:v0.0.5
restart: unless-stopped
tty: false
working_dir: /var/www
networks:
- app-network
- nats
ports:
- 8889:8889
volumes:
- storage-data:/var/www/storage
- database-data:/var/www/database
- ./nginx/conf.d/app.conf:/etc/nginx/http.d/app.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
environment:
- "LIVEWIRE_TEMPORARY_FILE_UPLOAD_MAX_FILE_UPLOAD_TIME=5"
- "ADMIN_EMAIL=${ADMIN_EMAIL}"
- "APP_NAME=${APP_NAME}"
- "APP_ENV=production"
- "APP_KEY=${APP_KEY}"
- "APP_DEBUG=false"
- "APP_URL=${APP_URL}"
- "APP_LOCALE=en"
- "APP_FALLBACK_LOCALE=en"
- "APP_MAINTENANCE_DRIVER=file"
- "PHP_CLI_SERVER_WORKERS=4"
- "BCRYPT_ROUNDS=12"
- "LOG_CHANNEL=stack"
- "LOG_STACK=single"
- "LOG_DEPRECATIONS_CHANNEL=null"
- "LOG_LEVEL=${LOG_LEVEL}"
- "DB_CONNECTION=sqlite"
- "SESSION_DRIVER=database"
- "SESSION_LIFETIME=120"
- "SESSION_ENCRYPT=false"
- "SESSION_PATH=/"
- "SESSION_DOMAIN=null"
- "BROADCAST_CONNECTION=log"
- "FILESYSTEM_DISK=s3"
- "QUEUE_CONNECTION=database"
- "CACHE_STORE=database"
- "CACHE_PREFIX=laravel_"
- "MAIL_MAILER=smtp"
- "MAIL_SCHEME=smtp"
- "MAIL_HOST=${MAIL_HOST}"
- "MAIL_PORT=${MAIL_PORT}"
- "MAIL_USERNAME=${MAIL_USERNAME}"
- "MAIL_PASSWORD=${MAIL_PASSWORD}"
- "MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS}"
- "MAIL_FROM_NAME=${APP_NAME}"
- "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}"
- "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}"
- "AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}"
- "AWS_BUCKET=${AWS_BUCKET}"
- "AWS_USE_PATH_STYLE_ENDPOINT=${AWS_USE_PATH_STYLE_ENDPOINT}"
- "AWS_DIRECTORY=${AWS_DIRECTORY}"
- "MEDIA_DISK=${MEDIA_DISK}"
- "VITE_APP_NAME=${APP_NAME}"
- "NATS_URL=${NATS_URL}"
- "NATS_USERNAME=${NATS_USERNAME}"
- "NATS_PASSWORD=${NATS_PASSWORD}"
- "NATS_STREAM=${NATS_STREAM}"
- "NATS_SUBJECT=${NATS_SUBJECT}"
volumes:
storage-data:
database-data:
nats-data:
networks:
app-network:
nats:
external: true

View file

@ -0,0 +1,84 @@
services:
app:
image: quay.io/marshyon/share-lt:v0.0.5
restart: unless-stopped
tty: false
working_dir: /var/www
networks:
- app-network
- traefik-net9
volumes:
- storage-data:/var/www/storage
- database-data:/var/www/database
- ./nginx/conf.d/app.conf:/etc/nginx/http.d/app.conf
- ./docker/php/php.ini:/usr/local/etc/php/php.ini
environment:
- "LIVEWIRE_TEMPORARY_FILE_UPLOAD_MAX_FILE_UPLOAD_TIME=5"
- "ADMIN_EMAIL=${ADMIN_EMAIL}"
- "APP_NAME=${APP_NAME}"
- "APP_ENV=production"
- "APP_KEY=${APP_KEY}"
- "APP_DEBUG=false"
- "APP_URL=${APP_URL}"
- "APP_LOCALE=en"
- "APP_FALLBACK_LOCALE=en"
- "APP_MAINTENANCE_DRIVER=file"
- "PHP_CLI_SERVER_WORKERS=4"
- "BCRYPT_ROUNDS=12"
- "LOG_CHANNEL=stack"
- "LOG_STACK=single"
- "LOG_DEPRECATIONS_CHANNEL=null"
- "LOG_LEVEL=${LOG_LEVEL}"
- "DB_CONNECTION=sqlite"
- "SESSION_DRIVER=database"
- "SESSION_LIFETIME=120"
- "SESSION_ENCRYPT=false"
- "SESSION_PATH=/"
- "SESSION_DOMAIN=null"
- "BROADCAST_CONNECTION=log"
- "FILESYSTEM_DISK=s3"
- "QUEUE_CONNECTION=database"
- "CACHE_STORE=database"
- "CACHE_PREFIX=laravel_"
- "MAIL_MAILER=smtp"
- "MAIL_SCHEME=smtp"
- "MAIL_HOST=${MAIL_HOST}"
- "MAIL_PORT=${MAIL_PORT}"
- "MAIL_USERNAME=${MAIL_USERNAME}"
- "MAIL_PASSWORD=${MAIL_PASSWORD}"
- "MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS}"
- "MAIL_FROM_NAME=${APP_NAME}"
- "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}"
- "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}"
- "AWS_DEFAULT_REGION=${AWS_DEFAULT_REGION}"
- "AWS_BUCKET=${AWS_BUCKET}"
- "AWS_USE_PATH_STYLE_ENDPOINT=${AWS_USE_PATH_STYLE_ENDPOINT}"
- "AWS_DIRECTORY=${AWS_DIRECTORY}"
- "MEDIA_DISK=${MEDIA_DISK}"
- "VITE_APP_NAME=${APP_NAME}"
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-net9"
- "traefik.http.services.${APP_SERVICE_NAME}.loadbalancer.server.port=${APP_PORT}"
- "traefik.http.services.${APP_SERVICE_NAME}.loadbalancer.server.scheme=http"
- "traefik.http.routers.${APP_SERVICE_NAME}.service=${APP_SERVICE_NAME}"
- "traefik.http.routers.${APP_SERVICE_NAME}.tls.certresolver=le"
- "traefik.http.routers.${APP_SERVICE_NAME}.rule=Host(`${APP_DOMAIN}`)"
- "traefik.http.routers.${APP_SERVICE_NAME}.entrypoints=websecure"
- "traefik.http.routers.${APP_SERVICE_NAME}.tls=true"
volumes:
storage-data:
database-data:
networks:
app-network:
traefik-net9:
external: true

13
nats/nats-server.conf Normal file
View file

@ -0,0 +1,13 @@
# Client port of 4222 on all interfaces
port: 4222
# HTTP monitoring port
monitor_port: 8222
# Enable JetStream and specify the storage directory
jetstream: {
store_dir: "/opt/storage"
}
# Set a unique server name for this instance
server_name: "nats-server-1"

31
nginx/conf.d/app.conf Normal file
View file

@ -0,0 +1,31 @@
server {
listen 8889;
# Redirect all HTTP requests to HTTPS
# return 301 https://$host$request_uri;
index index.php index.html;
# error_log /var/log/nginx/error.log;
error_log /dev/stderr debug;
# access_log /var/log/nginx/access.log;
access_log /dev/stdout;
root /var/www/public;
location ~ \.php$ {
try_files $uri =404;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS on;
fastcgi_param HTTP_X_FORWARDED_PROTO https;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
gzip_static on;
}
location /storage/ {
alias /var/www/storage/app/public/;
autoindex on;
}
}

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex)})},syncActionModals(t){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}if(this.actionNestingIndex!==null&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),Livewire.hook("commit",({component:e,commit:t,succeed:i,fail:o,respond:h})=>{i(({snapshot:r,effect:l})=>{this.$nextTick(()=>{e.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function h({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{h as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};

View file

@ -1 +0,0 @@
function r({initialHeight:i,shouldAutosize:s,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),s?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=i+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let e=this.$el.style.height;this.$el.style.height="0px";let t=this.$el.scrollHeight+"px";this.$el.style.height=e,this.wrapperEl.style.height!==t&&(this.wrapperEl.style.height=t)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};

View file

@ -1 +0,0 @@
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};

View file

@ -1 +0,0 @@
function o({isSkippable:s,isStepPersistedInQueryString:i,key:r,startStep:h,stepQueryStringKey:n}){return{step:null,init(){this.$watch("step",()=>this.updateQueryString()),this.step=this.getSteps().at(h-1),this.autofocusFields()},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.autofocusFields(),this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(){this.$nextTick(()=>this.$refs[`step-${this.step}`].querySelector("[autofocus]")?.focus())},getStepIndex(t){let e=this.getSteps().findIndex(p=>p===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return s||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!i)return;let t=new URL(window.location.href);t.searchParams.set(n,this.step),history.replaceState(null,document.title,t.toString())}}}export{o as default};

View file

@ -1 +0,0 @@
(()=>{var d=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let t=this.$el.parentElement;t&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(t),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let t=this.$el.parentElement;if(!t)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=t.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var u=function(t,e,n){let i=t;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)i=i.includes(".")?i.slice(0,i.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(i)?e:["",null,void 0].includes(e)?i:`${i}.${e}`},c=t=>{let e=Alpine.findClosest(t,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:t})=>({handleFormValidationError(e){e.detail.livewireId===t&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let i=n;for(;i;)i.dispatchEvent(new CustomEvent("expand")),i=i.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:t,containerPath:e,$wire:n})=>({$statePath:t,$get:(i,s)=>n.$get(u(e,i,s)),$set:(i,s,a,o=!1)=>n.$set(u(e,i,a),s,o),get $state(){return n.$get(t)}})),window.Alpine.data("filamentActionsSchemaComponent",d),Livewire.hook("commit",({component:t,commit:e,respond:n,succeed:i,fail:s})=>{i(({snapshot:a,effects:o})=>{o.dispatches?.forEach(r=>{if(!r.params?.awaitSchemaComponent)return;let l=Array.from(t.el.querySelectorAll(`[wire\\:partial="schema-component::${r.params.awaitSchemaComponent}"]`)).filter(h=>c(h)===t);if(l.length!==1){if(l.length>1)throw`Multiple schema components found with key [${r.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${t.id}-${r.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(r.name,{detail:r.params}))},{once:!0})}})})})});})();

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:d,respond:u})=>{n(({snapshot:f,effect:h})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||this.getNormalizedState()===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e}}}export{o as default};

View file

@ -1 +0,0 @@
function o({name:i,recordKey:s,state:a}){return{error:void 0,isLoading:!1,state:a,init(){Livewire.hook("commit",({component:e,commit:r,succeed:n,fail:h,respond:u})=>{n(({snapshot:f,effect:d})=>{this.$nextTick(()=>{if(this.isLoading||e.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let t=this.getServerState();t===void 0||Alpine.raw(this.state)===t||(this.state=t)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let r=await this.$wire.updateTableColumnState(i,s,this.state);this.error=r?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)}}}export{o as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -20,7 +20,7 @@ class UploadImageAdminTest extends DuskTestCase
$filePath = base_path('tests/Browser/fixtures/robot.webp'); $filePath = base_path('tests/Browser/fixtures/robot.webp');
$this->browse(function (Browser $browser ) use ($user, $filePath) { $this->browse(function (Browser $browser) use ($user, $filePath) {
$this->loginUser($browser, $user); $this->loginUser($browser, $user);
$this->assertWithDebugPause( $this->assertWithDebugPause(
$browser, $browser,
@ -29,16 +29,15 @@ class UploadImageAdminTest extends DuskTestCase
->waitForLocation('/admin/media') ->waitForLocation('/admin/media')
->assertPathIs('/admin/media') ->assertPathIs('/admin/media')
->assertTitleContains('Media') ->assertTitleContains('Media')
->clickLink('New media') ->clickLink('Add')
->waitForText('Create Media') ->waitForText('Create Asset')
->type('#form\\.name', 'test image') ->type('#form\\.alt_text', 'test image')
->assertVisible('.filepond--drop-label') ->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath) ->attach('.filepond--browser', $filePath)
->pause(7000) ->pause(7000)
->waitForText('Create') ->waitForText('Create')
->waitFor('#key-bindings-1:not([disabled])') ->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1') ->click('#key-bindings-1'),
->assertSee('Collection name'),
1000 // Custom pause time for this test 1000 // Custom pause time for this test
); );
}); });

View file

@ -27,17 +27,16 @@ class CreateEntryAdminTest extends DuskTestCase
->waitForLocation('/admin/media') ->waitForLocation('/admin/media')
->assertPathIs('/admin/media') ->assertPathIs('/admin/media')
->assertTitleContains('Media') ->assertTitleContains('Media')
->clickLink('New media') ->clickLink('Add')
->waitForText('Create Media') ->waitForText('Create Asset')
->type('#form\\.name', 'test image') ->type('#form\\.alt_text', 'test image')
->assertVisible('.filepond--drop-label') ->assertVisible('.filepond--drop-label')
->attach('.filepond--browser', $filePath) ->attach('.filepond--browser', $filePath)
->pause(7000) ->pause(7000)
->waitForText('Create') ->waitForText('Create Asset')
->waitFor('#key-bindings-1:not([disabled])') ->waitFor('#key-bindings-1:not([disabled])')
->click('#key-bindings-1') ->click('#key-bindings-1')
->assertSee('Collection name') ->pause(1000)
->pause(5000)
->visit('/admin/entries') ->visit('/admin/entries')
->waitForLocation('/admin/entries') ->waitForLocation('/admin/entries')
@ -54,19 +53,12 @@ class CreateEntryAdminTest extends DuskTestCase
->assertSee('Updated at') ->assertSee('Updated at')
->visit('/admin/entries/1/edit') ->visit('/admin/entries/1/edit')
->waitForText('Edit TEST ENTRY') ->waitForText('Edit TEST ENTRY')
->pause(2000)
->waitForText('Featured Image') ->waitForText('Featured Image')
->click('#featured-picker-button') ->click('#featured-picker-button')
->pause(5000)
->waitForText('Select an existing image') ->waitForText('Select an existing image')
->click('.fi-select-input-btn') ->pause(3000),
->pause(2000) // TODO: find a way to select the image
->click('li:first-child')
->waitForText('Submit')
->clickAtXPath('//button[contains(., "Submit")]')
->waitForText('Edit TEST ENTRY')
->click('#key-bindings-1'),
// ->pause(20000),
1000 // Custom pause time for this test 1000 // Custom pause time for this test
); );
}); });