Compare commits

...

55 commits

Author SHA1 Message Date
jon brookes
eb0ec0e287 updated README 2026-02-20 17:00:38 +00:00
jon brookes
448469b03f updated changelog 2026-02-17 18:33:45 +00:00
jon brookes
510ad66dbf updated changelog 2026-02-17 18:05:46 +00:00
jon brookes
b3d63aa114 updated changelog 2026-02-17 17:57:06 +00:00
Jon Brookes
958d1ddcc1 Merge pull request 'feat: implement Change resource with CRUD functionality and migration' (#22) from feat/go-live-mechanism into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/22
2026-02-17 17:15:16 +01:00
jon brookes
5b0e55c4b2 feat: implement Change resource with CRUD functionality and migration
update image tags to v0.0.8 in build scripts and docker-compose files
2026-02-17 16:07:22 +00:00
Jon Brookes
64a7d1d2f4 feat: add navigation items for Preview and Live sites in AdminPanelProvider (#21)
Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/21
2026-02-15 18:10:20 +01:00
jon brookes
b6b841391c pusher issues 2026-02-15 11:47:11 +00:00
jon brookes
969ea504f3 fix: merge edits
chore: bump version
2026-02-15 10:37:43 +00:00
jon brookes
5646ddd9a3 fix/partial/CVE-2025-68121/crypto/tls 2026-02-14 18:13:56 +00:00
Jon Brookes
21147af908 feat/reverb (#20)
Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/20
2026-02-14 17:49:01 +01:00
Jon Brookes
74bc17d019 feat/queue-messages (#19)
Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/19
2026-02-09 18:09:35 +01:00
Jon Brookes
1a22fd156d feat/docker-compose-update (#18)
Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/18
2026-02-08 18:04:18 +01:00
Jon Brookes
fd43495e2d Merge pull request 'docker compose updates' (#17) from feat/docker-compose into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/17
2026-01-26 16:21:13 +01:00
jon brookes
53d96e48a7 docker compose updates 2026-01-26 15:20:11 +00:00
jon brookes
0d908a054b reverted config 2026-01-25 18:45:59 +00:00
jon brookes
d088e76c51 woodpecker config 2026-01-25 18:20:46 +00:00
jon brookes
862ebb90e8 woodpecker config 2026-01-25 18:15:44 +00:00
jon brookes
42da921477 woodpecker config 2026-01-25 18:14:51 +00:00
jon brookes
be4dabdec1 woodpecker config 2026-01-25 18:01:51 +00:00
jon brookes
cb1f098e75 woodpecker config 2026-01-25 17:45:39 +00:00
jon brookes
93d307358e woodpecker config 2026-01-25 17:30:57 +00:00
jon brookes
45ac11d4d3 updated woodpecker 2026-01-25 17:18:25 +00:00
jon brookes
502ef92943 updated woodpecker 2026-01-25 16:48:00 +00:00
jon brookes
578204a06e updated woodpecker 2026-01-25 16:45:03 +00:00
jon brookes
d6c735fbec updated woodpecker 2026-01-25 16:39:19 +00:00
jon brookes
f665483a7f update woodpecker build 2026-01-25 16:31:36 +00:00
jon brookes
245504fd03 update changelog 2026-01-25 16:22:08 +00:00
Jon Brookes
73d2e7d058 Merge pull request 'added container config' (#16) from feat/container-build into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/16
2026-01-25 17:07:35 +01:00
jon brookes
1000464b4a added container config 2026-01-25 16:05:00 +00:00
Jon Brookes
eec00e17fe Merge pull request 's3 config' (#15) from feat/feature-numbering-REBUILT into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/15
2026-01-24 18:44:00 +01:00
jon brookes
093c538752 s3 config 2026-01-24 17:25:18 +00:00
Jon Brookes
d9dca29bc9 Merge pull request 'add: image and blog import' (#14) from feature/import-blog-and-images into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/14
2026-01-19 23:34:45 +01:00
jon brookes
ccf38ef6f9 add: image and blog import
fix: category field to Entry model

feat: EntriesTable with searchable and sortable columns

chore: update CHANGELOG with recent additions and API updates
2026-01-19 22:27:06 +00:00
Jon Brookes
9f77f8b8d3 Merge pull request 'added url and call to action to entries migraiton and model' (#13) from feat/add-url-envents into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/13
2026-01-17 19:09:35 +01:00
jon brookes
6731b4c487 added url and call to action to entries migraiton and model
updated unit tests
2026-01-17 18:01:50 +00:00
Jon Brookes
0d688a0f82 Merge pull request 'fix: standardize JSON response format in TextWidgetController and update related tests' (#12) from fix/api-inconsistencies into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/12
2026-01-15 19:04:22 +01:00
jon brookes
d715a177bd fix: standardize JSON response format in TextWidgetController and update related tests 2026-01-15 18:02:04 +00:00
Jon Brookes
224654cabf Merge pull request 'feature: add api for categories, docker build and compose for development mode' (#11) from feat/add-container-build into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/11
2026-01-15 18:45:37 +01:00
jon brookes
f980be8e28 feature: add api for categories
feature: add initial docker build and compose for development mode
2026-01-15 17:39:59 +00:00
Jon Brookes
6bf486e52b Merge pull request 'feat: add category management' (#10) from feat/categories into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/10
2026-01-09 14:21:58 +01:00
jon brookes
9b9e1a8e29 feat: add category management
associate with entries and text widgets
2026-01-09 13:18:37 +00:00
Jon Brookes
c83028b4d4 Merge pull request 'feat: add TextWidget' (#9) from feat/add-text-widgets into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/9
2026-01-08 17:52:58 +01:00
jon brookes
c66645cbdd feat: add TextWidget
add TextWidget CRUD api

add basic tests for API
2026-01-08 16:50:03 +00:00
Jon Brookes
13cfd2961f Merge pull request 'feat: add tagging' (#8) from feat/add-tags into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/8
2026-01-08 14:44:21 +01:00
jon brookes
4e1824d49a feat: add tagging functionality
to entries model and related migrations
2026-01-08 13:41:18 +00:00
Jon Brookes
4afa656e94 Merge pull request 'feat: implement basic API' (#7) from feat/basic-api into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/7
2026-01-07 20:55:56 +01:00
jon brookes
6fbeedd50c feat: implement basic API
with authorization and validation
2026-01-07 19:52:23 +00:00
Jon Brookes
b033262bd7 Merge pull request 'feat: make sidebar collapsible' (#6) from test/write-an-article into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/6
2026-01-07 17:15:21 +01:00
jon brookes
e56db6634b feat: make sidebar collapsible 2026-01-07 16:11:33 +00:00
Jon Brookes
d187671a34 Merge pull request 'updated change log to include media library' (#5) from chore/update-change-log into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/5
2026-01-06 13:51:54 +01:00
jon brookes
7467d3c12a updated change log to include media library 2026-01-06 12:50:46 +00:00
Jon Brookes
56607285bd feat: integrate Spatie Media Library (#4)
- Added Spatie Media Library
- Added media library configuration file
- Updated Entry model to support media handling
- Added featured image upload with gallery selection and preview
- Added login tests with Dusk for user authentication
- Added Dusk test for featured image selection

Co-authored-by: jon brookes <marshyon@gmail.com>
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/4
2026-01-06 13:26:55 +01:00
Jon Brookes
6cf8d5dfd4 Merge pull request 'added initial entries model' (#3) from feat/add-first-model into dev
Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/3
2026-01-02 16:53:40 +01:00
jon brookes
a0a1c08ece added initial entries model 2026-01-02 15:46:42 +00:00
202 changed files with 9514 additions and 566 deletions

32
.dockerignore Normal file
View file

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

72
.env.dusk.local.example Normal file
View file

@ -0,0 +1,72 @@
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,5 +61,7 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1 AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
# AWS_BUCKET=share-lt-images
# MEDIA_DISK=s3
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View file

@ -13,8 +13,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2 - livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3 - livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
@ -497,3 +500,12 @@ Fortify is a headless authentication backend that provides authentication routes
- `Features::updatePasswords()` to let users change their passwords. - `Features::updatePasswords()` to let users change their passwords.
- `Features::resetPasswords()` for password reset via email. - `Features::resetPasswords()` for password reset via email.
</laravel-boost-guidelines> </laravel-boost-guidelines>
=== docker/core rules ===
## Over-Engineering & Bloat
- Do not add unnecessary boilerplate or "nice-to-have" features.
- Only implement what solves the immediate problem.
- Ask before adding optional infrastructure or configuration sections.
- If a system worked before without something, don't add it "just in case".
- Minimize configuration, complexity, and dependencies.

11
.gitignore vendored
View file

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

View file

@ -0,0 +1,64 @@
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,7 +1,67 @@
# CHANGELOG # 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 ## 2026-01-01
added: laravel 12 added: laravel 12
added: AGPLv3 added: AGPLv3

107
Dockerfile Normal file
View file

@ -0,0 +1,107 @@
# 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,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/fortify (FORTIFY) - v1 - laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2 - livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v3 - livewire/livewire (LIVEWIRE) - v3
- laravel/dusk (DUSK) - v8
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1

View file

@ -219,7 +219,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
test share-lt
Copyright (C) 2026 jon Copyright (C) 2026 jon
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View file

@ -1,6 +1,48 @@
# share-lt # share-lt
Share Light CMS 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
this project is in 'Alpha' this project is in 'Alpha'

View file

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

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

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

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

30
app/Events/TestEvent.php Normal file
View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,58 @@
<?php
namespace App\Filament\Resources\Entries;
use App\Filament\Resources\Entries\Pages\CreateEntry;
use App\Filament\Resources\Entries\Pages\EditEntry;
use App\Filament\Resources\Entries\Pages\ListEntries;
use App\Filament\Resources\Entries\Pages\ViewEntry;
use App\Filament\Resources\Entries\Schemas\EntryForm;
use App\Filament\Resources\Entries\Schemas\EntryInfolist;
use App\Filament\Resources\Entries\Tables\EntriesTable;
use App\Models\Entry;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class EntryResource extends Resource
{
protected static ?string $model = Entry::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::PencilSquare;
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return EntryForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return EntryInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return EntriesTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListEntries::route('/'),
'create' => CreateEntry::route('/create'),
'view' => ViewEntry::route('/{record}'),
'edit' => EditEntry::route('/{record}/edit'),
];
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Entries\Pages;
use App\Filament\Resources\Entries\EntryResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewEntry extends ViewRecord
{
protected static string $resource = EntryResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,544 @@
<?php
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\Str;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class EntryForm
{
public static function configure(Schema $schema): Schema
{
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)
->afterStateUpdated(function ($state, $set): void {
$set('slug', Str::slug((string) $state));
}),
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'))
->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')
->icon('heroicon-m-photo')
->extraAttributes(['id' => 'featured-picker-button'])
->schema([
Select::make('image_id')
->label('Select an existing image')
->allowHtml()
->options(function () {
$currentDisk = config('media-library.disk_name', 'public');
return Media::where('disk', $currentDisk)
->latest()
->limit(50)
->get(['id', 'file_name', 'name', 'disk'])
->mapWithKeys(function (Media $item) {
try {
$url = $item->getUrl();
$fileName = e($item->file_name);
$name = e($item->name ?? '');
$html = "<div class='flex items-center gap-3'>" .
"<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />" .
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
'</div></div>';
return [$item->id => $html];
} catch (\Exception $e) {
return [];
}
})->toArray();
})
->searchable()
->preload()
->required(),
])
->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) {
\Filament\Notifications\Notification::make()
->warning()
->title('Save the entry first')
->send();
return;
}
if (! $data['image_id']) {
return;
}
$sourceMedia = Media::find($data['image_id']);
if (! $sourceMedia) {
Log::error('Source media not found', ['image_id' => $data['image_id']]);
\Filament\Notifications\Notification::make()
->danger()
->title('Source image not found in database')
->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);
}
// Verify record has ID
if (! $record->id) {
\Filament\Notifications\Notification::make()
->danger()
->title('Entry must be saved first')
->send();
return;
}
// Add the copy to the entry's featured-image collection
$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()
]);
}
// Dispatch event for app.js to handle
$component->getLivewire()->dispatch('featured-image-added', ['mediaId' => $newMedia->id]);
\Filament\Notifications\Notification::make()
->success()
->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)) {
unlink($tempCopy);
}
}
})
),
Toggle::make('is_published')
->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'),
RichEditor::make('content')
->visible(fn($get) => $get('type') !== 'image')
->columnSpanFull()
->hintAction(
Action::make('picker')
->label('Gallery Picker')
->icon('heroicon-m-photo')
->schema([
Select::make('image_url')
->label('Select an existing image')
->allowHtml()
->options(function () {
return Media::latest()
->limit(30) // Limit to 30 most recent items for performance
->get(['id', 'file_name', 'name', 'uuid', 'collection_name', 'model_type', 'model_id', 'disk'])
->filter(function (Media $item) {
// Only include media items that have a valid disk
return $item->disk !== null;
})
->mapWithKeys(function (Media $item) {
try {
$url = $item->getUrl();
} catch (\Exception $e) {
// Skip items that can't generate URLs
return [];
}
$fileName = e($item->file_name);
$name = e($item->name ?? '');
// Smaller image preview for better performance
$html = "<div class='flex items-center gap-3'>" .
"<img src='{$url}' class='rounded' style='width:60px;height:60px;object-fit:cover;' alt='{$fileName}' loading='lazy' />" .
"<div class='flex flex-col'>" .
"<span class='font-medium text-sm'>{$name}</span>" .
"<span class='text-xs text-gray-500'>{$fileName}</span>" .
'</div></div>';
return [$url => $html];
})->toArray();
})
->searchable()
->preload()
->required(),
])
->action(function (array $data, RichEditor $component) {
// We dispatch the URL to the browser to be inserted into TipTap
$component->getLivewire()->dispatch('insert-editor-content', [
'statePath' => $component->getStatePath(),
'html' => "<img src='{$data['image_url']}' alt=''>",
]);
})
),
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\Entries\Schemas;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\SpatieMediaLibraryImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class EntryInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextEntry::make('title'),
TextEntry::make('slug'),
TextEntry::make('description')
->placeholder('-')
->columnSpanFull(),
SpatieMediaLibraryImageEntry::make('featured_image')
->collection('featured-image')
->columnSpanFull(),
IconEntry::make('is_published')
->boolean(),
IconEntry::make('is_featured')
->boolean(),
TextEntry::make('published_at')
->date()
->placeholder('-'),
TextEntry::make('content')
->placeholder('-')
->columnSpanFull(),
TextEntry::make('created_at')
->dateTime()
->placeholder('-'),
TextEntry::make('updated_at')
->dateTime()
->placeholder('-'),
]);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Filament\Resources\Entries\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class EntriesTable
{
public static function configure(Table $table): Table
{
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(),
IconColumn::make('is_published')
->label('pub')
->boolean(),
IconColumn::make('is_featured')
->label('feat')
->boolean(),
TextColumn::make('published_at')
->date()
->sortable(),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
EditAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\Media;
use App\Filament\Resources\Media\Pages\CreateMedia;
use App\Filament\Resources\Media\Pages\EditMedia;
use App\Filament\Resources\Media\Pages\ListMedia;
use App\Filament\Resources\Media\Pages\ViewMedia;
use App\Filament\Resources\Media\Schemas\MediaForm;
use App\Filament\Resources\Media\Schemas\MediaInfolist;
use App\Filament\Resources\Media\Tables\MediaTable;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MediaResource extends Resource
{
protected static ?string $model = Media::class;
protected static ?string $recordTitleAttribute = 'file_name';
protected static string|BackedEnum|null $navigationIcon = Heroicon::Photo
;
public static function form(Schema $schema): Schema
{
return MediaForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return MediaInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return MediaTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListMedia::route('/'),
'create' => CreateMedia::route('/create'),
'view' => ViewMedia::route('/{record}'),
'edit' => EditMedia::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class CreateMedia extends CreateRecord
{
protected static string $resource = MediaResource::class;
protected ?string $uploadedFile = null;
protected function mutateFormDataBeforeCreate(array $data): array
{
// Extract the file data before creating the media record
$file = $data['file'] ?? null;
unset($data['file']);
// Store file path for later
$this->uploadedFile = $file;
// Set required fields for Media model
$data['model_type'] = $data['model_type'] ?? 'temp';
$data['model_id'] = $data['model_id'] ?? 0;
$data['collection_name'] = $data['collection_name'] ?? 'default';
$data['disk'] = $data['disk'] ?? 'public';
$data['file_name'] = $file ? basename($file) : '';
$data['mime_type'] = $file && Storage::disk('public')->exists($file)
? Storage::disk('public')->mimeType($file)
: 'application/octet-stream';
$data['size'] = $file && Storage::disk('public')->exists($file)
? Storage::disk('public')->size($file)
: 0;
$data['manipulations'] = [];
$data['custom_properties'] = [];
$data['generated_conversions'] = [];
$data['responsive_images'] = [];
return $data;
}
protected function afterCreate(): void
{
if ($this->uploadedFile && $this->record) {
$disk = Storage::disk('public');
// Create the directory for this media ID (Spatie structure: {id}/{filename})
$mediaDirectory = (string) $this->record->id;
$disk->makeDirectory($mediaDirectory);
// Move file from temporary upload location to Spatie's expected location
if ($disk->exists($this->uploadedFile)) {
$newPath = $mediaDirectory.'/'.$this->record->file_name;
$disk->move($this->uploadedFile, $newPath);
}
}
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Storage;
class EditMedia extends EditRecord
{
protected static string $resource = MediaResource::class;
protected ?string $uploadedFile = null;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
// Extract the file data if a new file was uploaded
$file = $data['file'] ?? null;
unset($data['file']);
// Only update file-related fields if a new file was uploaded
if ($file && $file !== $this->record->getPathRelativeToRoot()) {
$this->uploadedFile = $file;
// Keep the original file_name to prevent breaking existing references
// $data['file_name'] is not updated - we preserve the original filename
$data['mime_type'] = Storage::disk('public')->exists($file)
? Storage::disk('public')->mimeType($file)
: 'application/octet-stream';
$data['size'] = Storage::disk('public')->exists($file)
? Storage::disk('public')->size($file)
: 0;
}
return $data;
}
protected function afterSave(): void
{
if ($this->uploadedFile && $this->record) {
$disk = Storage::disk('public');
$mediaDirectory = (string) $this->record->id;
// Delete old file if it exists
$oldPath = $mediaDirectory.'/'.$this->record->getOriginal('file_name');
if ($disk->exists($oldPath)) {
$disk->delete($oldPath);
}
// Move new file to Spatie's expected location using the original filename
if ($disk->exists($this->uploadedFile)) {
$disk->makeDirectory($mediaDirectory);
// Use the original file_name to preserve existing references
$newPath = $mediaDirectory.'/'.$this->record->file_name;
$disk->move($this->uploadedFile, $newPath);
}
// Redirect to the same page to refresh the form state
$this->redirect(static::getUrl(['record' => $this->record]), navigate: true);
}
}
}

View file

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

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Media\Pages;
use App\Filament\Resources\Media\MediaResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewMedia extends ViewRecord
{
protected static string $resource = MediaResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make(),
];
}
}

View file

@ -0,0 +1,90 @@
<?php
namespace App\Filament\Resources\Media\Schemas;
use Filament\Forms\Components\FileUpload;
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
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('name')
->required()
->maxLength(255),
TextInput::make('collection_name')
->default('default')
->required()
->maxLength(255),
Hidden::make('disk')
->default('public'),
FileUpload::make('file')
->multiple() // workaround for Filament v4 single-file bug
->label('File')
->imageEditor()
->imageEditorAspectRatios([
'16:9',
'4:3',
'1:1',
])
->columnSpanFull()
->disk('s3')
->directory('media')
->visibility('public')
->acceptedFileTypes(['image/*', 'application/pdf'])
->maxSize(10240)
->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;
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

@ -0,0 +1,36 @@
<?php
namespace App\Filament\Resources\Media\Schemas;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Schema;
class MediaInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
ImageEntry::make('file_name')
->label('Preview')
->getStateUsing(fn ($record) => $record->getUrl())
->visible(fn ($record) => $record->mime_type && str_starts_with($record->mime_type, 'image/')),
TextEntry::make('name'),
TextEntry::make('file_name'),
TextEntry::make('mime_type'),
TextEntry::make('collection_name'),
TextEntry::make('size')
->formatStateUsing(fn ($state) => number_format($state / 1024, 2).' KB'),
TextEntry::make('model_type')
->label('Attached to Model'),
TextEntry::make('model_id'),
TextEntry::make('custom_properties')
->formatStateUsing(fn ($state) => json_encode($state, JSON_PRETTY_PRINT)),
TextEntry::make('created_at')
->dateTime(),
TextEntry::make('updated_at')
->dateTime(),
]);
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Filament\Resources\Media\Tables;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class MediaTable
{
public static function configure(Table $table): Table
{
return $table
->modifyQueryUsing(fn($query) => $query->where('collection_name', '!=', 'avatars'))
->columns([
ImageColumn::make('url')
->label('Preview')
->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'))
? Storage::url($record->getCustomProperty('stored_path'))
: $record->getUrl()
)
->height(40)
->width(40),
TextColumn::make('name')
->searchable(),
TextColumn::make('file_name')
->searchable(),
TextColumn::make('collection_name')
->badge(),
TextColumn::make('mime_type'),
TextColumn::make('size')
->formatStateUsing(fn($state) => number_format($state / 1024, 2) . ' KB'),
TextColumn::make('created_at')
->dateTime(),
])
->filters([
SelectFilter::make('collection_name')
->options([
'images' => 'Images',
'documents' => 'Documents',
]),
])
->recordActions([
// 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.
$stored = $record->getCustomProperty('stored_path');
if ($stored) {
Storage::disk($record->disk)->delete($stored);
} else {
Storage::disk($record->disk)->delete($record->getPath());
}
$record->delete();
}),
])
->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) {
$records->each(function (Media $record) {
$stored = $record->getCustomProperty('stored_path');
if ($stored) {
Storage::disk($record->disk)->delete($stored);
} else {
Storage::disk($record->disk)->delete($record->getPath());
}
$record->delete();
});
}),
]),
]);
}
}

View file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,108 @@
<?php
namespace App\Livewire;
use Livewire\Component;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class GalleryPicker extends Component
{
#[\Livewire\Attributes\Reactive]
public $entryId;
public $mediaItems = [];
public $selectedMediaId = null;
public $showModal = false;
#[\Livewire\Attributes\On('open-gallery-picker')]
public function openPicker($entryId): void
{
$this->entryId = $entryId;
$this->loadMediaItems();
$this->showModal = true;
}
public function loadMediaItems(): void
{
$this->mediaItems = Media::where('model_type', 'temp')
->where('model_id', 0)
->where('disk', 'public')
->latest()
->limit(30)
->get(['id', 'file_name', 'name', 'disk'])
->toArray();
}
public function selectMedia($mediaId): void
{
$this->selectedMediaId = $mediaId;
}
public function copyToEntry(): void
{
if (!$this->selectedMediaId || !$this->entryId) {
$this->dispatch('notify-error', ['message' => 'Please select an image']);
return;
}
$sourceMedia = Media::find($this->selectedMediaId);
if (!$sourceMedia) {
$this->dispatch('notify-error', ['message' => 'Media not found']);
return;
}
try {
// Get the entry
$entry = \App\Models\Entry::find($this->entryId);
if (!$entry) {
$this->dispatch('notify-error', ['message' => 'Entry not found']);
return;
}
// Get source file
$sourceFile = $sourceMedia->getPath();
if (!file_exists($sourceFile)) {
$this->dispatch('notify-error', ['message' => 'Source file not found']);
return;
}
// Create temp copy
$tempCopy = sys_get_temp_dir() . '/' . uniqid() . '_' . $sourceMedia->file_name;
copy($sourceFile, $tempCopy);
try {
// Clear existing featured image
$entry->clearMediaCollection('featured-image');
// Add to entry
$newMedia = $entry->addMedia($tempCopy)
->usingName($sourceMedia->name ?: pathinfo($sourceMedia->file_name, PATHINFO_FILENAME))
->usingFileName($sourceMedia->file_name)
->toMediaCollection('featured-image', 'public');
// Close modal and notify
$this->showModal = false;
$this->selectedMediaId = null;
$this->dispatch('media-selected', ['mediaId' => $newMedia->id, 'fileName' => $newMedia->file_name]);
$this->dispatch('notify-success', ['message' => 'Image added to entry']);
} finally {
if (file_exists($tempCopy)) {
unlink($tempCopy);
}
}
} catch (\Exception $e) {
$this->dispatch('notify-error', ['message' => 'Error: ' . $e->getMessage()]);
}
}
public function closePicker(): void
{
$this->showModal = false;
$this->selectedMediaId = null;
}
public function render()
{
return view('livewire.gallery-picker');
}
}

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

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

13
app/Models/Category.php Normal file
View file

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

19
app/Models/Change.php Normal file
View file

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

68
app/Models/Entry.php Normal file
View file

@ -0,0 +1,68 @@
<?php
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 HasMedia, HasRichContent
{
use HasFactory, HasTags, 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
*/
public function setUpRichContent(): void
{
$this->registerRichContent('content')
->fileAttachmentProvider(
SpatieMediaLibraryFileAttachmentProvider::make()
->collection('content-attachments')
->preserveFilenames()
);
}
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);
}
}

24
app/Models/TextWidget.php Normal file
View file

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

@ -3,16 +3,20 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; // use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable; use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -61,4 +65,17 @@ class User extends Authenticatable
->map(fn ($word) => Str::substr($word, 0, 1)) ->map(fn ($word) => Str::substr($word, 0, 1))
->implode(''); ->implode('');
} }
/**
* Determine if the user can access Filament admin panel.
*/
public function canAccessPanel(Panel $panel): bool
{
return $this->email === config('app.admin_email') || $this->role === 'admin';
}
public function changes()
{
return $this->hasMany(Change::class);
}
} }

View file

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

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

View file

@ -2,7 +2,12 @@
namespace App\Providers; 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\ServiceProvider;
use Illuminate\Support\Facades\File;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -19,6 +24,13 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
// // Ensure the livewire-tmp directory exists
$livewireTmpPath = storage_path('framework/livewire-tmp');
if (!File::exists($livewireTmpPath)) {
File::makeDirectory($livewireTmpPath, 0755, true);
}
Entry::observe(EntryObserver::class);
Change::observe(ChangeObserver::class);
} }
} }

View file

@ -10,6 +10,8 @@ use Filament\Pages\Dashboard;
use Filament\Panel; use Filament\Panel;
use Filament\PanelProvider; use Filament\PanelProvider;
use Filament\Support\Colors\Color; use Filament\Support\Colors\Color;
use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Filament\Widgets\AccountWidget; use Filament\Widgets\AccountWidget;
use Filament\Widgets\FilamentInfoWidget; use Filament\Widgets\FilamentInfoWidget;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
@ -23,13 +25,38 @@ class AdminPanelProvider extends PanelProvider
{ {
public function panel(Panel $panel): Panel 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 return $panel
->default() ->default()
->sidebarCollapsibleOnDesktop()
->id('admin') ->id('admin')
->path('admin') ->path('admin')
->login() ->login()
->colors([ ->colors([
'primary' => Color::Amber, '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') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages') ->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
@ -41,6 +68,7 @@ class AdminPanelProvider extends PanelProvider
AccountWidget::class, AccountWidget::class,
FilamentInfoWidget::class, FilamentInfoWidget::class,
]) ])
->navigationItems($navigationItems)
->middleware([ ->middleware([
EncryptCookies::class, EncryptCookies::class,
AddQueuedCookiesToResponse::class, AddQueuedCookiesToResponse::class,
@ -56,4 +84,151 @@ class AdminPanelProvider extends PanelProvider
Authenticate::class, Authenticate::class,
]); ]);
} }
public function boot(): void
{
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>';
}
);
}
} }

View file

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

15
cmd/backup_raw_data.sh Executable file
View file

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

11
cmd/build_container.sh Executable file
View file

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

10
cmd/build_prod_container.sh Executable file
View file

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

15
cmd/curl_get_categories.sh Executable file
View file

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

14
cmd/curl_get_categories_anon.sh Executable file
View file

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

14
cmd/curl_get_entries.sh Executable file
View file

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

12
cmd/curl_get_entries_anon.sh Executable file
View file

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

15
cmd/curl_get_text_widget.sh Executable file
View file

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

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

20
cmd/curl_post_entry.sh Executable file
View file

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

16
cmd/curl_post_entry_anon.sh Executable file
View file

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

20
cmd/curl_post_text_widget.sh Executable file
View file

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

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

@ -0,0 +1,11 @@
#!/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!"}'

37
cmd/docker-entrypoint.sh Normal file
View file

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

33
cmd/handle_cms_updates.sh Executable file
View file

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

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