Compare commits

..

24 commits

Author SHA1 Message Date
jon brookes
22393b5954 feat: change order of Dusk tests
for login, image upload, and entry creation in admin panel
2026-01-05 19:04:04 +00:00
jon brookes
56ce59fc22 feat: add DuskTestCase for enhanced browser testing setup 2026-01-05 18:48:13 +00:00
jon brookes
cfb9353475 feat: extend Pest with DuskTestCase for browser testing 2026-01-05 18:44:24 +00:00
jon brookes
b0fc008530 feat: complete image upload to entry test
enhance entry creation test to select existing images and submit
2026-01-05 18:42:02 +00:00
jon brookes
33688b55be feat: add critical testing approach guidelines for Laravel Dusk 2026-01-05 18:38:05 +00:00
jon brookes
50e5fb7f3f feat: enhance media entry test to select existing images 2026-01-05 17:40:22 +00:00
jon brookes
ae662a30ef feat: custom class in entry form
and update Dusk test for featured image selection
2026-01-05 17:17:48 +00:00
jon brookes
bef3ae7f41 feat: implement user authentication traits
and tests for admin panel image uploads
2026-01-05 16:37:06 +00:00
jon brookes
4cb9d078b1 initial partically working 2026-01-05 15:05:27 +00:00
jon brookes
1e35e485ad initial partically working 2026-01-05 14:56:40 +00:00
jon brookes
ff5ab3aa58 feat: add admin email configuration to app settings
delete: remove outdated Spatie media library decision document

docs: create new decision document for Spatie media library usage

docs: add testing strategy document for Pest4 and Dusk

test: implement login tests with Dusk for user authentication

chore: add .gitignore files for console, screenshots, and source directories
2026-01-05 13:43:07 +00:00
jon brookes
aa39707e10 fix: correct PSR-4 autoload configuration in composer.json 2026-01-04 16:17:38 +00:00
jon brookes
9f8c8d43f5 added filament spatie lib back in - must be needed after all 2026-01-04 16:11:02 +00:00
jon brookes
93c977d1f5 added fixes: warning
some files likely wont be needed and wer added by ai to fix things that were no longer needed !!!
2026-01-03 17:26:18 +00:00
jon brookes
8e1650653b fix: partical images broken
public seems to be holding

still issues with picking images - not saved
2026-01-03 15:12:34 +00:00
jon brookes
6d1d88542e fix: storage to public
private was breaking things bad
2026-01-03 14:52:52 +00:00
jon brookes
a94a34ce3b fix: storage to public
this was breaking things bad
2026-01-03 14:52:04 +00:00
jon brookes
c49249ee20 fix: partical fix for featured
imaes are added, seem semi permanent

disapapar in media lib
2026-01-03 14:25:23 +00:00
jon brookes
340b466ade feat: enhance featured image upload
with gallery selection and preview
2026-01-03 13:35:38 +00:00
jon brookes
9f01d44c9d added feaured image to entry
edit form now has image upload for featured image

table view for entries shows featured image

view entry shows featured image
2026-01-03 13:22:14 +00:00
jon brookes
d24b9b0732 removed: filament/spatie-laravel-media-library-plugin 2026-01-03 12:59:56 +00:00
jon brookes
02884d4e2b feat: implement Spatie Media Library integration
with CRUD operations and media management UI
2026-01-02 18:59:24 +00:00
jon brookes
d40b87438d fix: add render hook for Vite in AdminPanelProvider 2026-01-02 16:57:30 +00:00
jon brookes
5ea0ddce23 feat: integrate Spatie Media Library and update configuration
- Added Spatie Media Library dependencies to composer.json
- Created media table migration for media management
- Added media library configuration file
- Updated Entry model to support media handling
- Updated .gitignore to exclude Vite files
- Added basic logging to app.js
2026-01-02 16:56:48 +00:00
178 changed files with 879 additions and 7017 deletions

View file

@ -1,32 +0,0 @@
# Ignore .env files
.env
.env.*
.envrc
# Ignore node_modules
node_modules/
# Ignore vendor folder
vendor/
# Ignore log files
*.log
# Ignore IDE and editor files
.idea/
.vscode/
# Ignore system files
.DS_Store
Thumbs.db
# Ignore Laravel storage
/storage/*.key
/storage/*.log
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/storage/logs/*
# Ignore database files if in development
database/database.sqlite

View file

@ -1,72 +0,0 @@
DUSK_HEADLESS_DISABLED=true
ADMIN_EMAIL=login-test@example.com
APP_ENV=testing
DB_DATABASE=database/dusk.sqlite
APP_NAME=Laravel
APP_KEY=base64:YOUR_GENERATED_KEY_HERE=
APP_DEBUG=true
APP_URL=http://127.0.0.1:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
MEDIA_DISK=s3
VITE_APP_NAME="${APP_NAME}"

View file

@ -61,7 +61,5 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# AWS_BUCKET=share-lt-images
# MEDIA_DISK=s3
VITE_APP_NAME="${APP_NAME}"

View file

@ -13,8 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
@ -46,6 +44,13 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
## Testing Approach - CRITICAL
- **THE APPLICATION IS 100% WORKING** - All functionality works perfectly in production.
- When writing or debugging browser tests (Laravel Dusk), focus ONLY on test syntax, selectors, and Dusk interaction methods.
- NEVER assume the application has bugs or suggest app fixes - the issue is always in the test code.
- Trust the existing functionality and work on getting the correct CSS selectors, XPath expressions, and Dusk methods.
- If manual interaction works but the test fails, the problem is the test implementation, not the app.
=== boost rules ===
@ -500,12 +505,3 @@ Fortify is a headless authentication backend that provides authentication routes
- `Features::updatePasswords()` to let users change their passwords.
- `Features::resetPasswords()` for password reset via email.
</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.

8
.gitignore vendored
View file

@ -9,7 +9,6 @@
.env
.env.backup
.env.production
.env.dev
.phpactor.json
.phpunit.result.cache
Homestead.json
@ -25,10 +24,3 @@ yarn-error.log
.vite
*.deleted
.env.dusk.local
log*.txt
.envrc
database/backups
*backup.tar.gz
public/css
public/js
notes

View file

@ -1,64 +0,0 @@
when:
- event: push
branch: dev
steps:
build-local:
image: docker:24-dind
privileged: true
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "Pulling base images to ensure latest layers..."
- docker pull --quiet php:8.4-fpm-alpine3.23 || true
- echo "Try to pull previous image to use as cache ..."
- docker pull quay.io/marshyon/share-lt:latest || true
- 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 .
- echo "Tagging test image as quay.io/marshyon/share-lt:v0.0.8..."
- docker tag share-lt:test quay.io/marshyon/share-lt:v0.0.8
- 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.8 -o cyclonedx-json > sbom.json
scan-vulnerabilities:
image: aquasec/trivy:0.67.2
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "Ensuring latest Trivy image is pulled..."
- docker pull aquasec/trivy:latest || true
- echo "Scanning for vulnerabilities via Docker daemon..."
# Disabling scan for testing, will re-enable once a fix for
# vulnerability is available.
# Scan the image present in the Docker daemon; fail on CRITICAL severities
# - trivy image --exit-code 1 --severity CRITICAL --no-progress share-lt:test
# Run a full scan without failing just for logs
- trivy image --severity HIGH,MEDIUM,LOW --no-progress share-lt:test
- echo "Generating vulnerability report..."
- trivy image --format cyclonedx --output trivy-vuln-bom.json share-lt:test
- echo "Vulnerability Summary:"
- trivy image --format table share-lt:test | tee trivy-vuln-summary.txt
publish:
image: woodpeckerci/plugin-docker-buildx
settings:
registry: quay.io
repo: quay.io/marshyon/share-lt
platforms: linux/amd64
tags:
- v0.0.8
- latest
username:
from_secret: QUAY_USERNAME
password:
from_secret: QUAY_PASSWORD
upload-sbom:
image: cgr.dev/chainguard/cosign:latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
COSIGN_REGISTRY_USERNAME:
from_secret: QUAY_USERNAME
COSIGN_REGISTRY_PASSWORD:
from_secret: QUAY_PASSWORD
commands:
- cosign attach sbom --sbom sbom.json quay.io/marshyon/share-lt:v0.0.8 || echo "SBOM attach failed"
- echo "Done - trivy report saved to workspace for manual review"

View file

@ -1,67 +1,12 @@
# CHANGELOG
## 2026-02-17
added reverb, echo and toast messages for site builds
added change log for go-live logging and audit
## 2026-02-09
added reference compose files
added basic NATS integration
## 2026-01-25
added s3, docker build
## 2026-01-19
added text widgets
added categories
updated api to access text widgets and categories
added url and call to action for entries for use in cards
added import image and blogs import commands
## 2026-01-08
added tags to entry model
added text widget and category
## 2026-01-07
added simple API for entries model
- to view entries
- implement initial access control
this is sufficient to test static site generation
## 2026-01-06
added
- Spatie Media Library
- media library configuration file
- Updated Entry model to support media handling
- featured image upload with gallery selection and preview
- login tests with Dusk for user authentication
- Dusk test for featured image selection
## 2026-01-02
added initial model and filament resource
## 2026-01-01
added: laravel 12
added: AGPLv3
## 2026-01-02
added initial model and filament resource

View file

@ -1,107 +0,0 @@
# Build stage for NATS CLI
FROM golang:1.26-alpine AS nats-builder
RUN apk add --no-cache git
RUN git clone --depth 1 https://github.com/nats-io/natscli.git /src
WORKDIR /src/nats
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o nats .
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 \
unzip \
bash \
jq \
&& rm -rf /var/cache/apk/*
COPY --from=nats-builder /src/nats/nats /usr/local/bin/nats
RUN chmod +x /usr/local/bin/nats
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
# Copy entrypoint script
COPY cmd/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy supervisord configuration
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
# Create www user and add to www-data group
RUN adduser -u 1000 -G www-data -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
# Remove the semicolon to uncomment the listen directive
RUN sed -i 's/;listen = 127.0.0.1:9000/listen = 9000/' /usr/local/etc/php-fpm.d/www.conf
# Ensure the worker running the code is correct (usually www-data or nginx)
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
# Update nginx.conf to use 'www' user instead of 'nginx'
RUN sed -i 's/user nginx;/user www;/' /etc/nginx/nginx.conf
# Remove user and group directives from nginx and php-fpm configs to avoid conflicts
RUN sed -i '/^user /d' /etc/nginx/nginx.conf
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
# Set permissions for nginx directories
RUN mkdir -p /var/lib/nginx/tmp/client_body /var/log/nginx \
&& chown -R www:www-data /var/lib/nginx /var/log/nginx \
&& chmod -R 755 /var/lib/nginx /var/log/nginx \
&& touch /run/nginx/nginx.pid \
&& chown www:www-data /run/nginx/nginx.pid
# Copy application code (includes database/migrations/) and excluding
# files in .dockerignore
COPY --chown=www:www-data . /var/www
RUN chown -R www:www-data /var/www
RUN chown -R www:www-data /var/log/supervisor
# Switch to www user
USER www
# Install app dependencies
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

@ -13,8 +13,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8

View file

@ -1,48 +1,6 @@
# share-lt
Share Light CMS - Headless CMS with Real-time Publishing
[![status-badge](https://wpk.headshed.dev/api/badges/2/status.svg)](https://wpk.headshed.dev/repos/2) [![Docker Repository on Quay](https://quay.io/repository/marshyon/share-lt/status "Docker Repository on Quay")](https://quay.io/repository/marshyon/share-lt)
## Overview
Share Light is a modern headless CMS built with Laravel 12 that enables real-time content management and automated static site generation. It provides a complete backend solution for content creators and developers who need a robust, scalable CMS with live preview capabilities.
## Architecture
This application runs as a **fat container** orchestrated by **tini** and **supervisor**, managing multiple services:
- **Laravel 12** - Core CMS application with Filament admin interface
- **PHP-FPM** - PHP process manager
- **nginx** - Web server and reverse proxy
- **Queue Worker** - Background job processing
- **Laravel Reverb** - WebSocket server for real-time communication
### Integration with External Services
- **NATS Messaging** - Publishes messages to NATS streams when content changes
- **share-lt-astro-consumer** - Companion application that consumes NATS messages to rebuild static sites
- **Real-time Notifications** - Uses Reverb to send toast alerts to users about build status
## Features
### Content Management
- **Entry Model** - Core content entities with media library support
- **Text Widgets** - Reusable content blocks
- **Categories** - Content organization and taxonomy
- **Media Library** - Image and file management with Spatie Media Library
### Real-time Publishing Workflow
1. **Content Updates** - When entries are modified, messages are automatically sent to NATS streams
2. **Consumer Notifications** - External applications (like share-lt-astro-consumer) listen for these messages
3. **Preview Generation** - Consumers rebuild preview websites based on content changes
4. **Status Updates** - Build results are sent back via API
5. **Live Alerts** - Users receive real-time toast notifications about build status through Reverb
### API & Integration
- **RESTful API** - Complete API for content retrieval and management
- **Access Control** - Built-in authorization and authentication
- **NATS Integration** - Message streaming for distributed architecture
Share Light CMS
this project is in 'Alpha'

View file

@ -1,144 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\Entry;
class ImportBlogs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:import-blogs';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
/*
{
"id": 7,
"title": "Low/No Code FlowiseAI",
"slug": "lowno-code-flowiseai",
"content": "<p><img alt=\"\" src=\"http://127.0.0.1:8000/storage/99/ZsTHWAQRbdvgRUEwGmCIsm4TyChIjBwiY71VmnnR.webp\"/></p>\n<p>In <a href=\"https://flowiseai.com/\">FlowiseAI</a>, applications based on the JavaScript fork of LangChain are modeled in a 'no/low-code' environment. If you are coming from a closed source world, yet trying to implement devops principles this may fill you with fear, dread and uncertainty. FlowiseAI offers the advantage of using plain-text JSON files to represent each workflow. These files are easy to understand, open, and readily backup-able, unlike opaque proprietary binary formats\nThe data used at runtime and other component prerequisites like credentials are stored in the FlowiseAI data volume, which looks like this</p>\n<p><code>bash\nMode LastWriteTime ..... 8ZS1C0ZBB.webp",
"created_at": "2024-12-17 11:24:59",
"updated_at": "2024-12-17 12:53:54",
"category_id": 1,
"blog_date": "2024-02-26 00:00",
"is_featured": 0,
"published": 1
},
*/
$filePath = '/home/user/projects/laravel/12/media_library/boring-astro-static/imported_database/blogs.json';
if (!file_exists($filePath)) {
$this->error("File not found: $filePath");
return 1;
}
$jsonContent = file_get_contents($filePath);
$blogs = json_decode($jsonContent, true);
if (!$blogs) {
$this->error("Could not parse JSON file: $filePath");
return 1;
}
foreach ($blogs as $blog) {
// Only process the blog with ID 51
if (($blog['id'] ?? null) !== 51) {
continue;
}
$slug = $blog['slug'] ?? null;
$this->info("Processing blog ID: {$blog['id']} with slug: {$slug}");
// Check if the entry already exists
$existingEntry = Entry::where('slug', $slug)->first();
if ($existingEntry) {
// Update existing entry with cleaned content
$existingEntry->update([
'content' => $this->cleanHtmlForFilament($blog['content'] ?? ''),
'description' => $this->extractPlainTextFromHtml($blog['content'] ?? ''),
]);
$this->info("Updated content for: {$existingEntry->title}");
} else {
// Create new entry
Entry::create([
'title' => $blog['title'] ?? null,
'slug' => $slug,
'description' => $this->extractPlainTextFromHtml($blog['content'] ?? ''),
'is_published' => $blog['published'] ?? false,
'is_featured' => $blog['is_featured'] ?? false,
'published_at' => $blog['blog_date'] ?? null,
'content' => $this->cleanHtmlForFilament($blog['content'] ?? ''),
'category_id' => 1, // Default category
]);
$this->info("Created new entry: " . ($blog['title'] ?? 'Untitled'));
}
}
$this->info('Blogs imported successfully.');
}
/**
* Extract plain text from HTML for description field
*/
private function extractPlainTextFromHtml(string $html): string
{
if (empty($html)) {
return '';
}
// Decode HTML entities and strip tags
$text = html_entity_decode(strip_tags($html));
// Clean up whitespace
$text = preg_replace('/\s+/', ' ', $text);
// Limit to reasonable length for description
return trim(substr($text, 0, 500));
}
/**
* Clean HTML content for Filament rich editor
*/
private function cleanHtmlForFilament(string $html): string
{
if (empty($html)) {
return '';
}
// Convert escaped newlines to actual newlines
$html = str_replace(['\\n', '\\r\\n', '\\r'], "\n", $html);
// Decode HTML entities
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Clean up excessive whitespace but preserve paragraph structure
$html = preg_replace('/\s*\n\s*/', ' ', $html);
$html = preg_replace('/[ \t]+/', ' ', $html);
// Ensure paragraphs have proper spacing
$html = str_replace('</p><p>', '</p>' . "\n" . '<p>', $html);
return trim($html);
}
}

View file

@ -1,61 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Entry;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class ImportImages extends Command
{
protected $signature = 'app:import-images {--entry-id=1}';
protected $description = 'Import images into the media library';
public function handle(): void
{
$entryId = $this->option('entry-id');
$entry = Entry::find($entryId);
if (!$entry) {
$this->error("Entry with ID {$entryId} not found");
return;
}
$files = Storage::disk('public')->files('imported_images');
if (empty($files)) {
$this->info('No files found in storage/app/public/imported_images/');
return;
}
$this->info("Found " . count($files) . " files to import");
foreach ($files as $filePath) {
try {
$fullPath = storage_path('app/public/' . $filePath);
$fileName = pathinfo($fullPath, PATHINFO_BASENAME);
if (!file_exists($fullPath)) {
$this->error("File not found: {$fullPath}");
continue;
}
// Check if already exists
$existingMedia = $entry->getMedia()->where('file_name', $fileName)->first();
if ($existingMedia) {
$this->info("Skipping existing: {$fileName}");
continue;
}
$media = $entry->addMedia($fullPath)
->toMediaCollection('default');
$this->info("Imported: {$fileName}");
} catch (\Exception $e) {
$this->error("Failed to import {$filePath}: " . $e->getMessage());
}
}
$this->info('Import completed');
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MoveMediaToPublic extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:move-to-public {--dry-run : Show what would be moved without actually moving files}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Move all media files from private/local disk to public disk';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
if ($dryRun) {
$this->warn('DRY RUN MODE - No files will actually be moved');
}
// Get all media records using local disk
$mediaRecords = Media::where('disk', 'local')->get();
if ($mediaRecords->isEmpty()) {
$this->info('No media records found using local disk.');
return self::SUCCESS;
}
$this->info("Found {$mediaRecords->count()} media records to migrate.");
$progressBar = $this->output->createProgressBar($mediaRecords->count());
$progressBar->start();
$moved = 0;
$errors = 0;
foreach ($mediaRecords as $media) {
// Use relative path: {id}/{filename}
$relativePath = $media->id . '/' . $media->file_name;
// Check if source file exists
if (!Storage::disk('local')->exists($relativePath)) {
$this->newLine();
$this->error("Source file not found: {$relativePath}");
$errors++;
$progressBar->advance();
continue;
}
try {
if (!$dryRun) {
// Copy file from local to public disk
$fileContent = Storage::disk('local')->get($relativePath);
Storage::disk('public')->put($relativePath, $fileContent);
// Verify the file was copied successfully
if (Storage::disk('public')->exists($relativePath)) {
// Update the database record
$media->update([
'disk' => 'public',
'conversions_disk' => 'public',
]);
// Delete the old file from local disk
Storage::disk('local')->delete($relativePath);
$moved++;
} else {
throw new \Exception("Failed to copy file to public disk");
}
} else {
$this->newLine();
$this->line("Would move: local:{$relativePath} -> public:{$relativePath}");
$moved++;
}
} catch (\Exception $e) {
$this->newLine();
$this->error("Error moving {$relativePath}: {$e->getMessage()}");
$errors++;
}
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
if ($dryRun) {
$this->info("DRY RUN: Would move {$moved} files, {$errors} errors encountered.");
$this->info("Run without --dry-run to actually perform the migration.");
} else {
$this->info("Successfully moved {$moved} files, {$errors} errors encountered.");
}
return self::SUCCESS;
}
}

View file

@ -1,61 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Entry;
use Illuminate\Support\Facades\Log;
use function Livewire\str;
class RemediateBlogS3Images extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:remediate-blog-s3-images';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Command description';
/**
* Execute the console command.
*/
public function handle()
{
$stringToFind = 'src="http://127.0.0.1:8000/storage';
$stringToReplace = 'src="https://your-s3-bucket-here/yours-site-static-media-dir-here';
Log::info('RemediateBlogS3Images command executed.');
foreach (Entry::all() as $entry) {
$this->info('Entry ID: ' . $entry->id);
if (str_contains($entry->content, $stringToFind)) {
$this->info(' - Found occurrence in entry ID: ' . $entry->id);
// Extract all image srcs that match the pattern
preg_match_all('/src=\"http:\/\/127.0.0.1:8000\/storage([^\"]*)/', $entry->content, $matches);
if (!empty($matches[0])) {
foreach ($matches[0] as $i => $foundUrl) {
$this->info(' - Found image src: ' . $foundUrl);
// Compute the replacement for this specific image
$relativePath = $matches[1][$i] ?? '';
$newUrl = 'src="https://your-s3-bucket-here/yours-site-static-media-dir-here' . $relativePath;
$this->info(' - Will replace with: ' . $newUrl);
}
}
$updatedContent = \str_replace($stringToFind, $stringToReplace, $entry->content);
// uncomment the following when your sure about the changes
// $entry->content = $updatedContent;
// $entry->save();
// $this->info(' - Updated entry ID: ' . $entry->id);
}
}
}
}

View file

@ -1,38 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Events\PreviewSiteBuilt;
use Illuminate\Console\Command;
class SendPreviewSiteBuiltNotification extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'test:preview-site-built {--message=Preview site is built}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Send a test notification that the preview site is built';
/**
* Execute the console command.
*/
public function handle(): void
{
$message = $this->option('message');
$this->info("Command :: Broadcasting preview site built notification: {$message}");
PreviewSiteBuilt::dispatch($message, 'success');
$this->info('Notification broadcasted successfully!');
$this->info('Check your Filament admin panel for the toast notification.');
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class PreviewSiteBuilt implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $message = 'Preview site is built',
public string $type = 'success'
) {}
public function broadcastOn(): array
{
return [
new Channel('filament-notifications'),
];
}
public function broadcastAs(): string
{
return 'preview-site.built';
}
}

View file

@ -1,30 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TestEvent implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $message = 'Test message from Laravel'
) {}
public function broadcastOn(): array
{
return [
new Channel('test-channel'),
];
}
public function broadcastAs(): string
{
return 'test.message';
}
}

View file

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

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

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

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

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

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

@ -1,50 +0,0 @@
<?php
namespace App\Filament\Resources\Categroys;
use App\Filament\Resources\Categroys\Pages\CreateCategroy;
use App\Filament\Resources\Categroys\Pages\EditCategroy;
use App\Filament\Resources\Categroys\Pages\ListCategroys;
use App\Filament\Resources\Categroys\Schemas\CategroyForm;
use App\Filament\Resources\Categroys\Tables\CategroysTable;
use App\Models\Category;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class CategroyResource extends Resource
{
protected static ?string $model = Category::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::RectangleGroup;
protected static ?string $recordTitleAttribute = 'name';
public static function form(Schema $schema): Schema
{
return CategroyForm::configure($schema);
}
public static function table(Table $table): Table
{
return CategroysTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListCategroys::route('/'),
'create' => CreateCategroy::route('/create'),
'edit' => EditCategroy::route('/{record}/edit'),
];
}
}

View file

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

View file

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

View file

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

View file

@ -1,20 +0,0 @@
<?php
namespace App\Filament\Resources\Categroys\Schemas;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class CategroyForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->label('Category Name')
->required()
->maxLength(255),
]);
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Filament\Resources\Categroys\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class CategroysTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Category Name')
->sortable()
->searchable(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -1,53 +0,0 @@
<?php
namespace App\Filament\Resources\Changes;
use App\Filament\Resources\Changes\Pages\CreateChange;
use App\Filament\Resources\Changes\Pages\EditChange;
use App\Filament\Resources\Changes\Pages\ListChanges;
use App\Filament\Resources\Changes\Schemas\ChangeForm;
use App\Filament\Resources\Changes\Tables\ChangesTable;
use App\Models\Change as ModelsChange;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class ChangeResource extends Resource
{
protected static ?string $model = ModelsChange::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::ArrowUpOnSquareStack;
public static function getNavigationGroup(): ?string
{
return 'Settings';
}
public static function form(Schema $schema): Schema
{
return ChangeForm::configure($schema);
}
public static function table(Table $table): Table
{
return ChangesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListChanges::route('/'),
'create' => CreateChange::route('/create'),
'edit' => EditChange::route('/{record}/edit'),
];
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace App\Filament\Resources\Changes\Pages;
use App\Filament\Resources\Changes\ChangeResource;
use Filament\Resources\Pages\CreateRecord;
class CreateChange extends CreateRecord
{
protected static string $resource = ChangeResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = auth()->id();
return $data;
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\Changes\Pages;
use App\Filament\Resources\Changes\ChangeResource;
use Filament\Resources\Pages\EditRecord;
class EditChange extends EditRecord
{
protected static string $resource = ChangeResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View file

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

View file

@ -1,28 +0,0 @@
<?php
namespace App\Filament\Resources\Changes\Schemas;
use Filament\Forms\Components\Select as FormSelect;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
class ChangeForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Textarea::make('note')
->label('Note')
->required(),
FormSelect::make('type')
->label('Type')
->options([
'go-live' => 'Go Live',
'general' => 'General',
])
->default('go-live')
->required(),
]);
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Filament\Resources\Changes\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class ChangesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('note')
->label('Note')
->wrap(),
TextColumn::make('type')
->label('Type'),
TextColumn::make('user.name')
->label('User'),
])
->filters([
//
])
->recordActions([])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -20,7 +20,7 @@ class EntryResource extends Resource
{
protected static ?string $model = Entry::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::PencilSquare;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
protected static ?string $recordTitleAttribute = 'title';

View file

@ -2,18 +2,16 @@
namespace App\Filament\Resources\Entries\Schemas;
use App\Models\Category;
use Filament\Actions\Action;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\SpatieTagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
@ -23,16 +21,6 @@ class EntryForm
{
return $schema
->components([
Select::make('type')
->options([
'article' => 'Article',
'card' => 'Card',
'text' => 'Text',
'image' => 'Image',
])
->default('article')
->required()
->live(),
TextInput::make('title')
->required()
->live(onBlur: true)
@ -41,269 +29,18 @@ class EntryForm
}),
TextInput::make('slug')
->required()
->visible(fn($get) => $get('type') === 'article')
->dehydrated()
->readOnly(),
Textarea::make('description')
->visible(fn($get) => $get('type') === 'article')
->columnSpanFull(),
SpatieTagsInput::make('tags')
->type('entry-tags')
->visible(fn($get) => $get('type') === 'article')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('featured_image')
->multiple() // <- force array handling for Filament v4 bug
->visible(
fn($get) =>
$get('type') === 'article' || $get('type') === 'image'
)
->collection('featured-image')
->image()
->imageEditor()
->disk(config('media-library.disk_name', 'public'))
->disk('public')
->visibility('public')
->columnSpanFull()
->dehydrated(false)
->saveUploadedFileUsing(function ($file, $record) {
if (is_array($file)) {
$file = reset($file);
}
// 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', [
'disk' => $diskName,
'file_name' => $file->getClientOriginalName(),
'file_size' => $file->getSize(),
'file_mime' => $file->getMimeType(),
'file_path' => $file->getRealPath(),
'record_id' => $record?->id,
'aws_config' => [
'bucket' => config('filesystems.disks.s3.bucket'),
'region' => config('filesystems.disks.s3.region'),
'key_exists' => !empty(config('filesystems.disks.s3.key')),
'secret_exists' => !empty(config('filesystems.disks.s3.secret')),
]
]);
}
try {
if (!$record) {
throw new \Exception('Record not found during upload');
}
// Test S3 connection if using S3
if ($diskName === 's3') {
$disk = \Storage::disk('s3');
// Test basic S3 connectivity
$testFile = 'test-' . time() . '.txt';
$disk->put($testFile, 'test content');
$disk->delete($testFile);
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('S3 connectivity test passed');
}
}
// Use addMedia with the file directly, not addMediaFromRequest
// Generate secure filename similar to Livewire temp files
$originalName = $file->getClientOriginalName();
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
$baseName = pathinfo($originalName, PATHINFO_FILENAME);
// Generate secure filename with encoded original name
$encodedName = base64_encode($originalName);
$secureFileName = Str::random(32) . '-meta' . $encodedName . '-.' . $extension;
// 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);
// 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', [
'media_id' => $media->id,
'media_url' => $media->getUrl(),
'media_path' => $media->getPathRelativeToRoot(),
'disk' => $media->disk
]);
}
return $media->getUrl();
} catch (\Exception $e) {
Log::error('Featured Image Upload Failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'disk' => $diskName,
'file_name' => $file->getClientOriginalName()
]);
// Also show error to user
\Filament\Notifications\Notification::make()
->danger()
->title('Upload Failed')
->body($e->getMessage())
->persistent()
->send();
throw $e;
}
})
->hintAction(
Action::make('featured_picker')
->label('Featured Image from Gallery')
@ -314,10 +51,11 @@ class EntryForm
->label('Select an existing image')
->allowHtml()
->options(function () {
$currentDisk = config('media-library.disk_name', 'public');
return Media::where('disk', $currentDisk)
return Media::where('model_type', 'temp')
->where('model_id', 0)
->where('disk', 'public')
->latest()
->limit(50)
->limit(30)
->get(['id', 'file_name', 'name', 'disk'])
->mapWithKeys(function (Media $item) {
try {
@ -330,7 +68,7 @@ class EntryForm
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
'</div></div>';
"</div></div>";
return [$item->id => $html];
} catch (\Exception $e) {
@ -344,73 +82,35 @@ class EntryForm
])
->action(function (array $data, SpatieMediaLibraryFileUpload $component): void {
$record = $component->getRecord();
$diskName = config('media-library.disk_name', 'public');
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Action Debug', [
'disk' => $diskName,
'record_id' => $record?->id,
'image_id' => $data['image_id'] ?? null
]);
}
if (! $record) {
if (!$record) {
\Filament\Notifications\Notification::make()
->warning()
->title('Save the entry first')
->send();
return;
}
if (! $data['image_id']) {
if (!$data['image_id']) {
return;
}
$sourceMedia = Media::find($data['image_id']);
if (! $sourceMedia) {
Log::error('Source media not found', ['image_id' => $data['image_id']]);
if (!$sourceMedia || !file_exists($sourceMedia->getPath())) {
\Filament\Notifications\Notification::make()
->danger()
->title('Source image not found in database')
->title('Image file not found')
->send();
return;
}
try {
// For S3, we need to handle file copying differently
if ($sourceMedia->disk === 's3') {
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Copying S3 media to new collection', [
'source_disk' => $sourceMedia->disk,
'source_path' => $sourceMedia->getPathRelativeToRoot(),
'target_disk' => $diskName
]);
}
// Copy from S3 to S3 or download temporarily
$sourceDisk = \Storage::disk($sourceMedia->disk);
$sourceContent = $sourceDisk->get($sourceMedia->getPathRelativeToRoot());
if (!$sourceContent) {
throw new \Exception('Could not read source file from S3');
}
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
file_put_contents($tempCopy, $sourceContent);
} else {
// Local file handling
$sourceFile = $sourceMedia->getPath();
if (! file_exists($sourceFile)) {
throw new \Exception('Source file not found on disk');
}
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
copy($sourceFile, $tempCopy);
}
try {
// Verify record has ID
if (! $record->id) {
if (!$record->id) {
\Filament\Notifications\Notification::make()
->danger()
->title('Entry must be saved first')
@ -422,15 +122,7 @@ class EntryForm
$newMedia = $record->addMedia($tempCopy)
->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME))
->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', $diskName);
if (config('logging.channels.' . config('logging.default') . '.level') === 'debug') {
Log::info('Featured Image Picker Success', [
'new_media_id' => $newMedia->id,
'new_media_disk' => $newMedia->disk,
'new_media_url' => $newMedia->getUrl()
]);
}
->toMediaCollection('featured-image', 'public');
// Dispatch event for app.js to handle
$component->getLivewire()->dispatch('featured-image-added', ['mediaId' => $newMedia->id]);
@ -440,20 +132,12 @@ class EntryForm
->title('Image added to featured image')
->send();
} catch (\Exception $e) {
Log::error('Featured Image Picker Failed', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
'source_media_id' => $data['image_id'],
'disk' => $diskName
]);
\Filament\Notifications\Notification::make()
->danger()
->title('Error: ' . $e->getMessage())
->persistent()
->send();
} finally {
if (isset($tempCopy) && file_exists($tempCopy)) {
if (file_exists($tempCopy)) {
unlink($tempCopy);
}
}
@ -463,30 +147,8 @@ class EntryForm
->required(),
Toggle::make('is_featured')
->required(),
TextInput::make('priority')
->label('Priority')
->numeric()
->default(0)
->required(),
DatePicker::make('published_at')
->visible(fn($get) => $get('type') === 'article'),
Select::make('category_id')
->label('Category')
->options(function () {
return Category::all()
->pluck('name', 'id')
->toArray();
})
->searchable(),
TextInput::make('call_to_action_text')
->label('Call to Action Text')
->visible(fn($get) => $get('type') !== 'article'),
TextInput::make('call_to_action_link')
->label('Call to Action URL')
->visible(fn($get) => $get('type') !== 'article'),
DatePicker::make('published_at'),
RichEditor::make('content')
->visible(fn($get) => $get('type') !== 'image')
->columnSpanFull()
->hintAction(
Action::make('picker')
@ -521,7 +183,7 @@ class EntryForm
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
'</div></div>';
"</div></div>";
return [$url => $html];
})->toArray();

View file

@ -17,33 +17,18 @@ class EntriesTable
{
return $table
->columns([
TextColumn::make('id')
->label('ID')
->sortable(query: function ($query, $direction) {
$query->orderBy('id', $direction);
}),
SpatieMediaLibraryImageColumn::make('featured_image')
->collection('featured-image')
->label('Image')
->circular()
->stacked()
->limit(3),
TextColumn::make('title')
->searchable()
->sortable(),
TextColumn::make('category.name')
->label('Category')
->sortable(query: function ($query, $direction) {
$query->join('categories', 'entries.category_id', '=', 'categories.id')
->orderBy('categories.name', $direction)
->select('entries.*');
})
->searchable(),
TextColumn::make('slug')
->searchable(),
IconColumn::make('is_published')
->label('pub')
->boolean(),
IconColumn::make('is_featured')
->label('feat')
->boolean(),
TextColumn::make('published_at')
->date()

View file

@ -22,9 +22,7 @@ class MediaResource extends Resource
protected static ?string $recordTitleAttribute = 'file_name';
protected static string|BackedEnum|null $navigationIcon = Heroicon::Photo
;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
public static function form(Schema $schema): Schema
{

View file

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

View file

@ -7,8 +7,6 @@ use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
use Spatie\MediaLibrary\MediaCollections\Models\Media as SpatieMedia;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class MediaForm
{
@ -26,7 +24,6 @@ class MediaForm
Hidden::make('disk')
->default('public'),
FileUpload::make('file')
->multiple() // workaround for Filament v4 single-file bug
->label('File')
->imageEditor()
->imageEditorAspectRatios([
@ -35,56 +32,28 @@ class MediaForm
'1:1',
])
->columnSpanFull()
->disk('s3')
->disk('public')
->directory('media')
->visibility('public')
->acceptedFileTypes(['image/*', 'application/pdf'])
->maxSize(10240)
->required(fn($context) => $context === 'create')
->required(fn ($context) => $context === 'create')
->afterStateHydrated(function (FileUpload $component, $state, $record): void {
Log::info('MediaForm afterStateHydrated invoked', ['record_id' => $record?->id, 'state' => $state]);
try {
if (! $record) {
return;
}
$media = $record;
if (! $media instanceof SpatieMedia) {
return;
}
// Construct the correct path: {media_id}/{filename}
$path = $media->id . '/' . $media->file_name;
$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;
}
}),
]);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,36 +0,0 @@
<?php
namespace App\Filament\Resources\TextWidgets\Schemas;
use App\Models\Category;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class TextWidgetForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('title')
->required()
->live(onBlur: true),
TextInput::make('description')
->nullable(),
Textarea::make('content')
->rows(5)
->columnSpanFull(),
Select::make('category_id')
->label('Category')
->options(function () {
return Category::all()
->pluck('name', 'id')
->toArray();
})
->searchable(),
]);
}
}

View file

@ -1,42 +0,0 @@
<?php
namespace App\Filament\Resources\TextWidgets\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class TextWidgetsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label('Title')
->sortable()
->searchable(),
TextColumn::make('created_at')
->label('Created')
->dateTime('M d, Y H:i')
->sortable(),
TextColumn::make('updated_at')
->label('Updated')
->dateTime('M d, Y H:i')
->sortable(),
])
->filters([
//
])
->recordActions([
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -1,53 +0,0 @@
<?php
namespace App\Filament\Resources\TextWidgets;
use App\Filament\Resources\TextWidgets\Pages\CreateTextWidget;
use App\Filament\Resources\TextWidgets\Pages\EditTextWidget;
use App\Filament\Resources\TextWidgets\Pages\ListTextWidgets;
use App\Filament\Resources\TextWidgets\Schemas\TextWidgetForm;
use App\Filament\Resources\TextWidgets\Tables\TextWidgetsTable;
use App\Models\TextWidget;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class TextWidgetResource extends Resource
{
protected static bool $shouldRegisterNavigation = false;
protected static ?string $model = TextWidget::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText;
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return TextWidgetForm::configure($schema);
}
public static function table(Table $table): Table
{
return TextWidgetsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTextWidgets::route('/'),
'create' => CreateTextWidget::route('/create'),
'edit' => EditTextWidget::route('/{record}/edit'),
];
}
}

View file

@ -1,61 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCategoryRequest;
use App\Http\Requests\UpdateCategoryRequest;
use App\Http\Resources\CategoryResource;
use App\Models\Category;
class CategoryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return CategoryResource::collection(Category::all());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreCategoryRequest $request)
{
return new CategoryResource(Category::create($request->validated()));
}
/**
* Display the specified resource.
*/
public function show(Category $category)
{
return new CategoryResource($category);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateCategoryRequest $request, Category $category)
{
$category->update($request->validated());
return new CategoryResource($category);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Category $category)
{
$user = auth()->user();
if (! $user || $user->email !== config('app.admin_email')) {
return response()->json(['message' => 'Forbidden'], 403);
}
$category->delete();
return response()->noContent();
}
}

View file

@ -1,93 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\EntryResource;
use App\Models\Entry;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class EntryController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return EntryResource::collection(Entry::with('category')->get());
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$user = Auth::user();
if (! $user || $user->email !== config('app.admin_email')) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
]);
$validated['slug'] = $this->generateUniqueSlug($validated['title']);
return new EntryResource(Entry::create($validated));
}
private function generateUniqueSlug(string $title): string
{
do {
$slug = Str::slug($title).'-'.Str::random(8);
} while (Entry::where('slug', $slug)->exists());
return $slug;
}
/**
* Display the specified resource.
*/
public function show(Entry $entry)
{
$this->authorize('view', $entry);
return new EntryResource($entry);
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Entry $entry)
{
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'content' => 'sometimes|required|string',
]);
$entry->update($validated);
return new EntryResource($entry);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Entry $entry)
{
$entry->delete();
return response()->noContent();
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Auth::check();
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Events\PreviewSiteBuilt;
use App\Http\Requests\SendPreviewSiteBuiltNotificationRequest;
use Illuminate\Http\JsonResponse;
class NotificationController extends Controller
{
/**
* Send preview site built notification.
*/
public function sendPreviewSiteBuilt(SendPreviewSiteBuiltNotificationRequest $request): JsonResponse
{
$message = $request->validated()['message'];
PreviewSiteBuilt::dispatch($message, 'success');
return response()->json([
'success' => true,
'message' => 'Preview site built notification sent successfully',
'data' => [
'notification_message' => $message,
],
]);
}
}

View file

@ -1,75 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Resources\TextWidgetResource;
use App\Models\TextWidget;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TextWidgetController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return response()->json([
'data' => TextWidget::with('category')->get()->map(fn ($tw) => new TextWidgetResource($tw))
]);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$user = Auth::user();
if (! $user || $user->email !== config('app.admin_email')) {
return response()->json(['message' => 'Forbidden'], 403);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'description' => 'nullable|string',
'content' => 'required|string',
]);
return new TextWidgetResource(TextWidget::create($validated));
}
/**
* Display the specified resource.
*/
public function show(TextWidget $textWidget)
{
return new TextWidgetResource($textWidget->load('category'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, TextWidget $textWidget)
{
$validated = $request->validate([
'title' => 'sometimes|required|string|max:255',
'description' => 'sometimes|nullable|string',
'content' => 'sometimes|required|string',
]);
$textWidget->update($validated);
return new TextWidgetResource($textWidget->load('category'));
}
/**
* Remove the specified resource from storage.
*/
public function destroy(TextWidget $textWidget)
{
$textWidget->delete();
return response()->noContent();
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class SendPreviewSiteBuiltNotificationRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'message' => 'required|string|max:255',
];
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255|unique:categories,name',
];
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string',
];
}
}

View file

@ -1,28 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => 'sometimes|required|string|max:255|unique:categories,name,'.$this->category->id,
];
}
}

View file

@ -1,29 +0,0 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEntryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() && $this->user()->email === config('app.admin_email');
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'title' => 'sometimes|required|string|max:255',
'content' => 'sometimes|required|string',
];
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CategoryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EntryResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'description' => $this->description,
'is_published' => $this->is_published,
'is_featured' => $this->is_featured,
'published_at' => $this->published_at,
'content' => $this->content,
'category' => $this->category->name ?? null,
'featured_image_url' => $this->getFirstMediaUrl('featured-image') ?: null,
'call_to_action_text' => $this->call_to_action_text,
'call_to_action_link' => $this->call_to_action_link,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
'priority' => $this->priority,
'type' => $this->type,
];
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TextWidgetResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'content' => $this->content,
'category' => $this->category->name ?? null,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
}
}

View file

@ -1,36 +0,0 @@
<?php
namespace App\Jobs;
use App\Services\ProcessUpdateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessChangeUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $changeId,
public string $action,
public string $type = 'change_update_'
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
Log::info("Processing change update: changeId={$this->changeId}, action={$this->action}");
(new ProcessUpdateService)->processUpdate($this->changeId, $this->action, $this->type);
}
}

View file

@ -1,34 +0,0 @@
<?php
namespace App\Jobs;
use App\Services\ProcessUpdateService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessEntryUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
public int $entryId,
public string $action,
public string $type = 'entry_update_'
) {
//
}
/**
* Execute the job.
*/
public function handle(): void
{
(new ProcessUpdateService)->processUpdate($this->entryId, $this->action, $this->type);
}
}

View file

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

@ -1,13 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Category extends Model
{
use HasFactory;
protected $fillable = ['name'];
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Change extends Model
{
protected $fillable = [
'note',
'user_id',
'type',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View file

@ -5,38 +5,27 @@ namespace App\Models;
use Filament\Forms\Components\RichEditor\FileAttachmentProviders\SpatieMediaLibraryFileAttachmentProvider;
use Filament\Forms\Components\RichEditor\Models\Concerns\InteractsWithRichContent;
use Filament\Forms\Components\RichEditor\Models\Contracts\HasRichContent;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Tags\HasTags;
/*
Entry model with rich content and media library integration
This is the main article / blog rich content model
*/
class Entry extends Model implements HasRichContent, HasMedia
class Entry extends Model implements HasMedia, HasRichContent
{
use HasFactory, HasTags, InteractsWithMedia, InteractsWithRichContent;
use InteractsWithMedia, InteractsWithRichContent;
protected $fillable = [
'title',
'type',
'slug',
'description',
'is_published',
'is_featured',
'published_at',
'content',
'call_to_action_link',
'call_to_action_text',
'category_id',
'priority',
];
/**
* Set up rich content configuration for media library integration
*/
@ -50,19 +39,4 @@ class Entry extends Model implements HasMedia, HasRichContent
);
}
protected static function boot()
{
parent::boot();
static::creating(function ($entry) {
if (empty($entry->slug)) {
$entry->slug = Str::slug($entry->title);
}
});
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TextWidget extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'content',
'category_id',
];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}

View file

@ -6,17 +6,18 @@ namespace App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Container\Attributes\Log;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Facades\Log as FacadesLog;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
use HasFactory, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
@ -71,11 +72,8 @@ class User extends Authenticatable implements FilamentUser
*/
public function canAccessPanel(Panel $panel): bool
{
return $this->email === config('app.admin_email') || $this->role === 'admin';
}
// FacadesLog::info('Checking admin access for user: ' . $this->email . ' against admin email: ' . config('app.admin_email'));
public function changes()
{
return $this->hasMany(Change::class);
return $this->email === config('app.admin_email');
}
}

View file

@ -1,49 +0,0 @@
<?php
namespace App\Observers;
use App\Jobs\ProcessChangeUpdate;
use App\Models\Change;
class ChangeObserver
{
/**
* Handle the Change "created" event.
*/
public function created(Change $change): void
{
ProcessChangeUpdate::dispatch($change->id, 'created');
}
/**
* Handle the Change "updated" event.
*/
public function updated(Change $change): void
{
ProcessChangeUpdate::dispatch($change->id, 'updated');
}
/**
* Handle the Change "deleted" event.
*/
public function deleted(Change $change): void
{
//
}
/**
* Handle the Change "restored" event.
*/
public function restored(Change $change): void
{
//
}
/**
* Handle the Change "force deletecURLd" event.
*/
public function forceDeleted(Change $change): void
{
//
}
}

View file

@ -1,50 +0,0 @@
<?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,12 +2,7 @@
namespace App\Providers;
use App\Models\Entry;
use App\Observers\EntryObserver;
use App\Models\Change;
use App\Observers\ChangeObserver;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\File;
class AppServiceProvider extends ServiceProvider
{
@ -24,13 +19,6 @@ class AppServiceProvider extends ServiceProvider
*/
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);
Change::observe(ChangeObserver::class);
//
}
}

View file

@ -25,39 +25,14 @@ class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
$previewSiteUrl = env('PREVIEW_SITE_URL');
$liveSiteUrl = env('LIVE_SITE_URL');
$navigationItems = [];
if ($previewSiteUrl && $liveSiteUrl) {
$navigationItems = [
\Filament\Navigation\NavigationItem::make('Preview Site')
->url($previewSiteUrl, shouldOpenInNewTab: true)
->icon('heroicon-o-eye')
->group('External Links')
->sort(1),
\Filament\Navigation\NavigationItem::make('Live Site')
->url($liveSiteUrl, shouldOpenInNewTab: true)
->icon('heroicon-o-rocket-launch')
->group('External Links')
->sort(2),
];
}
return $panel
->default()
->sidebarCollapsibleOnDesktop()
->id('admin')
->path('admin')
->login()
->colors([
'primary' => Color::Blue,
])
->resources([
\App\Filament\Resources\Entries\EntryResource::class,
\App\Filament\Resources\Media\MediaResource::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')
->pages([
@ -68,7 +43,6 @@ class AdminPanelProvider extends PanelProvider
AccountWidget::class,
FilamentInfoWidget::class,
])
->navigationItems($navigationItems)
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
@ -89,146 +63,7 @@ class AdminPanelProvider extends PanelProvider
{
FilamentView::registerRenderHook(
PanelsRenderHook::BODY_END,
fn(): string => \Illuminate\Support\Facades\Blade::render('@vite("resources/js/app.js")'),
);
FilamentView::registerRenderHook(
PanelsRenderHook::BODY_END,
function (): string {
return '
<script>
document.addEventListener("DOMContentLoaded", function() {
if (window.Echo) {
console.log("Setting up Filament Reverb notifications...");
console.log("Available globals:", {
FilamentNotification: typeof FilamentNotification,
Livewire: typeof window.Livewire,
filament: typeof window.filament
});
// Listen for preview site built events
window.Echo.channel("filament-notifications")
.listen("preview-site.built", function(event) {
console.log("🎉 Received preview site built event:", event);
// Use Filament v4 notification system
if (typeof FilamentNotification !== "undefined") {
new FilamentNotification()
.title(event.message)
.success()
.duration(5000)
.send();
console.log("✅ Sent via FilamentNotification v4");
} else {
console.warn("FilamentNotification not available");
// Fallback notification
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed !important;
top: 20px !important;
right: 20px !important;
z-index: 999999 !important;
background: #10b981 !important;
color: white !important;
padding: 16px 20px !important;
border-radius: 8px !important;
box-shadow: 0 10px 25px rgba(0,0,0,0.3) !important;
font-family: system-ui, -apple-system, sans-serif !important;
font-size: 14px !important;
font-weight: 500 !important;
max-width: 350px !important;
display: block !important;
opacity: 1 !important;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="margin-right: 15px;">
${event.message}
</div>
<button onclick="this.parentElement.parentElement.remove()"
style="background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0;">
×
</button>
</div>
`;
document.body.appendChild(notification);
console.log("✅ Created fallback notification");
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
})
.listen(".preview-site.built", function(event) {
console.log("🔄 Also listening with dot prefix (the working one):", event);
console.log("message:", event.message);
// Use Filament v4 notification system
if (typeof FilamentNotification !== "undefined") {
new FilamentNotification()
.title(event.message)
.success()
.duration(5000)
.send();
console.log("✅ Sent via FilamentNotification v4 (from dot prefix)");
} else {
console.warn("FilamentNotification not available, creating fallback");
// Fallback notification
const notification = document.createElement("div");
notification.style.cssText = `
position: fixed !important;
top: 20px !important;
right: 20px !important;
z-index: 999999 !important;
background: #3b82f6 !important;
color: white !important;
padding: 16px 20px !important;
border-radius: 8px !important;
box-shadow: 0 10px 25px rgba(0,0,0,0.3) !important;
font-family: system-ui, -apple-system, sans-serif !important;
font-size: 14px !important;
font-weight: 500 !important;
max-width: 350px !important;
display: block !important;
opacity: 1 !important;
`;
notification.innerHTML = `
<div style="display: flex; align-items: center; justify-content: space-between;">
<div style="margin-right: 15px;">
🔄 ${event.message}
</div>
<button onclick="this.parentElement.parentElement.remove()"
style="background: none; border: none; color: white; font-size: 18px; cursor: pointer; padding: 0;">
×
</button>
</div>
`;
document.body.appendChild(notification);
console.log("✅ Created dot prefix fallback notification");
// Auto-remove after 5 seconds
setTimeout(() => {
if (notification.parentNode) {
notification.remove();
}
}, 5000);
}
});
console.log("✅ Event listeners set up for filament-notifications channel");
} else {
console.error("❌ Echo is not available for Filament notifications");
}
});
</script>';
}
fn (): string => \Illuminate\Support\Facades\Blade::render('@vite("resources/js/app.js")'),
);
}
}

View file

@ -1,79 +0,0 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Symfony\Component\Console\Exception\RuntimeException;
class ProcessUpdateService
{
public function processUpdate(int $entryId, string $action, string $type = 'entry_update_'): void
{
$subject = env('NATS_SUBJECT', 'entry_update');
$appUrl = env('APP_URL', 'http://localhost');
$incoming = [
'id' => $entryId,
'action' => $action,
'subject' => $subject,
'app_url' => $appUrl,
'type' => $type,
];
$jsonData = json_encode($incoming, JSON_THROW_ON_ERROR);
$tempFile = tempnam(sys_get_temp_dir(), $type);
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: {$action}", [
'entry_id' => $entryId,
'temp_file' => $tempFile,
'script_exists' => file_exists($scriptPath),
'script_executable' => is_executable($scriptPath),
]);
$result = Process::run([
'bash',
$scriptPath,
$action,
$tempFile,
]);
if ($result->failed()) {
$errorDetails = [
'exit_code' => $result->exitCode(),
'stdout' => $result->output(),
'stderr' => $result->errorOutput(),
'command' => ['bash', $scriptPath, $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' => $entryId,
'action' => $action,
]);
} finally {
// Clean up temp file
if (file_exists($tempFile)) {
unlink($tempFile);
}
}
}
}

View file

@ -7,9 +7,7 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {

View file

@ -1,15 +0,0 @@
#!/usr/bin/env bash
#SQLITE_DB_PATH="../share-lt/database/database.sqlite"
SQLITE_DB_PATH="../share-lt/database/database.sqlite.backup5.recovery"
BACKUP_DIR="database/backups"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="$BACKUP_DIR/backup_$TIMESTAMP.sql"
TABLES="taggables media tags categories text_widgets entries users"
mkdir -p "$BACKUP_DIR"
for TABLE in $TABLES; do
echo "Backing up table: $TABLE"
sqlite3 "$SQLITE_DB_PATH" ".dump $TABLE" > "$BACKUP_DIR/${TABLE}_backup_$TIMESTAMP.sql"
done

View file

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

View file

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

View file

@ -1,15 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/categories'
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
$URL

View file

@ -1,14 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/categories'
curl -s -X GET \
-H "Accept: application/json" \
$URL

View file

@ -1,14 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# tokens need to be created with tinker or similar method
URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
$URL

View file

@ -1,12 +0,0 @@
#!/usr/bin/env bash
# this should fail, no token provided
# users need to be authenticated and have been
# granted access to view entries by being given
# a token
URL="${ADDRESS:-http://127.0.0.1:8000/api/entries}"
curl -s -X GET \
-H "Accept: application/json" \
$URL

View file

@ -1,15 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/text-widgets'
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
$URL

View file

@ -1,13 +0,0 @@
#!/usr/bin/env bash
# this should fail, no token provided
# users need to be authenticated and have been
# granted access to view text widgets by being given
# a token
URL='http://127.0.0.1:8000/api/text-widgets'
curl -s -X GET \
-H "Accept: application/json" \
$URL

View file

@ -1,20 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# only the admin user can create entries so this should
# fail unless .env has ADMIN_EMAIL set to the user that
# the token belongs to
URL='http://127.0.0.1:8000/api/entries'
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New Entry Title",
"content": "This is the content yet again of the new entry."
}' \
$URL

View file

@ -1,16 +0,0 @@
#!/usr/bin/env bash
# this should fail as no token is provided
# user is not authenticated
# no token has been granted
URL='http://127.0.0.1:8000/api/entries'
curl -X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New Entry Title",
"content": "This is the content yet again of the new entry."
}' \
$URL

View file

@ -1,20 +0,0 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# only the admin user can create entries so this should
# fail unless .env has ADMIN_EMAIL set to the user that
# the token belongs to
URL='http://127.0.0.1:8000/api/text-widgets'
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New text widget Title",
"content": "This is the content yet again of the new text widget."
}' \
$URL

View file

@ -1,16 +0,0 @@
#!/usr/bin/env bash
# this should fail as no token is provided
# user is not authenticated
# no token has been granted
URL='http://127.0.0.1:8000/api/text-widgets'
curl -X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New Entry Title",
"content": "This is the content yet again of the new entry."
}' \
$URL

View file

@ -1,11 +0,0 @@
#!/usr/bin/env bash
# URL='http://127.0.0.1:8000'
curl -X POST $URL/api/notifications/preview-site-built \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"message": "Published to LIVE 💯🚀🎯 - site notification!"}'

View file

@ -1,37 +0,0 @@
#!/bin/sh
set -e
# Ensure APP_KEY is set and persisted
PERSISTED_KEY="/var/www/storage/.app_key"
if [ -z "$APP_KEY" ]; then
if [ -f "$PERSISTED_KEY" ]; then
echo "Using persisted APP_KEY from: $PERSISTED_KEY"
export APP_KEY=$(cat "$PERSISTED_KEY")
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
# check to see if /var/www/database/database.sqlite exists
# if not, run migrations
if [ ! -f /var/www/database/database.sqlite ]; then
php artisan migrate --force
fi
# check to see if /var/www/public/storage exists
# if not, run storage:link
if [ ! -d /var/www/public/storage ]; then
php artisan storage:link
fi
php artisan config:clear
npm run build
# Start supervisord directly
exec "$@"

View file

@ -1,33 +0,0 @@
#!/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

@ -1,58 +0,0 @@
#!/bin/bash
# Define the backup directory and the SQLite database file
BACKUP_DIR="database/backups"
DB_FILE="database/database.sqlite"
# DATE="20260124_115644"
DATE="20260124_123340"
# Ensure the DATE variable is set
if [ -z "$DATE" ]; then
echo "DATE variable is not set. Please set the DATE variable to match the backup file date."
exit 1
fi
# Check if the backup directory exists
if [ ! -d "$BACKUP_DIR" ]; then
echo "Backup directory does not exist: $BACKUP_DIR"
exit 1
fi
# Check if the SQLite database file exists
if [ ! -f "$DB_FILE" ]; then
echo "SQLite database file does not exist: $DB_FILE"
exit 1
fi
# Check if there are any matching backup files
if ! ls "$BACKUP_DIR"/*"$DATE".sql 1> /dev/null 2>&1; then
echo "No backup files found for the date: $DATE"
exit 1
fi
echo "Starting restore process from backups dated: $DATE"
echo "Using database file: $DB_FILE"
# Loop through each file in the backup directory
for file in "$BACKUP_DIR"/*"$DATE".sql; do
if [ -f "$file" ]; then
echo "Restoring data from $file into $DB_FILE..."
# Import the SQL file into the SQLite database and log output
sqlite3 "$DB_FILE" < "$file" 2>> restore_errors.log
# Check for errors in the restore process
if [ $? -eq 0 ]; then
echo "Successfully restored $file"
else
echo "Failed to restore $file. Check restore_errors.log for details."
fi
# Debugging: Print the last 10 lines of the database to verify data
# echo "Last 10 rows of the database after restoring $file:"
# sqlite3 "$DB_FILE" "SELECT * FROM sqlite_master WHERE type='table';" 2>> restore_errors.log
# sqlite3 "$DB_FILE" "SELECT * FROM entries ORDER BY rowid DESC LIMIT 10;" 2>> restore_errors.log
fi
done
echo "Restore process completed."

View file

@ -9,16 +9,11 @@
"php": "^8.2",
"filament/filament": "^4.0",
"filament/spatie-laravel-media-library-plugin": "^4.4",
"filament/spatie-laravel-tags-plugin": "^4.0",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/reverb": "^1.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.0",
"livewire/flux": "^2.9.0",
"spatie/laravel-medialibrary": "^11.17",
"spatie/laravel-tags": "^4.10"
"spatie/laravel-medialibrary": "^11.17"
},
"require-dev": {
"fakerphp/faker": "^1.23",

1719
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,82 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_API_HOST', '127.0.0.1'),
'port' => env('REVERB_API_PORT', 9001),
'scheme' => env('REVERB_API_SCHEME', 'http'),
'useTLS' => env('REVERB_API_SCHEME', 'http') !== 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-' . env('PUSHER_APP_CLUSTER', 'mt1') . '.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View file

@ -16,18 +16,18 @@ return [
'broadcasting' => [
'echo' => [
'broadcaster' => 'reverb',
'key' => env('VITE_REVERB_APP_KEY'),
'cluster' => env('VITE_REVERB_APP_CLUSTER'),
'wsHost' => env('VITE_REVERB_HOST'),
'wsPort' => env('VITE_REVERB_PORT'),
'wssPort' => env('VITE_REVERB_PORT'),
'authEndpoint' => '/broadcasting/auth',
'disableStats' => true,
'encrypted' => env('VITE_REVERB_SCHEME', 'https') === 'https',
'forceTLS' => env('VITE_REVERB_SCHEME', 'https') === 'https',
],
// 'echo' => [
// 'broadcaster' => 'pusher',
// 'key' => env('VITE_PUSHER_APP_KEY'),
// 'cluster' => env('VITE_PUSHER_APP_CLUSTER'),
// 'wsHost' => env('VITE_PUSHER_HOST'),
// 'wsPort' => env('VITE_PUSHER_PORT'),
// 'wssPort' => env('VITE_PUSHER_PORT'),
// 'authEndpoint' => '/broadcasting/auth',
// 'disableStats' => true,
// 'encrypted' => true,
// 'forceTLS' => true,
// ],
],

View file

@ -41,7 +41,7 @@ return [
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL') . '/storage',
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
@ -53,7 +53,6 @@ return [
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'root' => env('AWS_DIRECTORY', ''),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),

View file

@ -1,95 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
],
],
],
];

View file

@ -1,84 +0,0 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View file

@ -26,7 +26,6 @@ return [
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'directory' => env('AWS_DIRECTORY'),
],
'slack' => [

View file

@ -1,28 +0,0 @@
<?php
return [
/*
* The given function generates a URL friendly "slug" from the tag name property before saving it.
* Defaults to Str::slug (https://laravel.com/docs/master/helpers#method-str-slug)
*/
'slugger' => null,
/*
* The fully qualified class name of the tag model.
*/
'tag_model' => Spatie\Tags\Tag::class,
/*
* The name of the table associated with the taggable morph relation.
*/
'taggable' => [
'table_name' => 'taggables',
'morph_name' => 'taggable',
/*
* The fully qualified class name of the pivot model.
*/
'class_name' => Illuminate\Database\Eloquent\Relations\MorphPivot::class,
]
];

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