diff --git a/.gemini/settings.json b/.gemini/settings.json
new file mode 100644
index 0000000..8c6715a
--- /dev/null
+++ b/.gemini/settings.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 103a6f3..b4af403 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -9,21 +9,10 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.16
-- laravel/fortify (FORTIFY) - v1
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- livewire/flux (FLUXUI_FREE) - v2
-- livewire/livewire (LIVEWIRE) - v3
-- livewire/volt (VOLT) - v1
-- laravel/mcp (MCP) - v0
-- laravel/pint (PINT) - v1
-- laravel/sail (SAIL) - v1
-- pestphp/pest (PEST) - v4
-- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
@@ -31,7 +20,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
+- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
@@ -43,17 +32,16 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Documentation Files
- You must only create documentation files if explicitly requested by the user.
-
=== boost rules ===
## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@@ -64,22 +52,21 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first.
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
-
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules ===
@@ -90,7 +77,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
### Constructors
- Use PHP 8 constructor property promotion in `__construct()`.
- public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations
- Always use explicit return type declarations for methods and functions.
@@ -104,7 +91,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate.
@@ -112,469 +99,44 @@ protected function isAccessible(User $user, ?string $path = null): bool
## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-=== herd rules ===
-
-## Laravel Herd
-
-- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
-- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
-
-
=== tests rules ===
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
-
-
-=== laravel/core rules ===
-
-## Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `php artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-### URL Generation
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-### Testing
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-
-=== laravel/v12 rules ===
-
-## Laravel 12
-
-- Use the `search-docs` tool to get version specific documentation.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-### Database
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
-
-
-
-=== livewire/core rules ===
-
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
-
-
-=== livewire/v3 rules ===
-
-## Livewire 3
-
-### Key Changes From Livewire 2
-- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
- - Use the `components.layouts.app` view as the typical layout path (not `layouts.app`).
-
-### New Directives
-- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
-
-### Alpine
-- Alpine is now included with Livewire, don't manually include Alpine.js.
-- Plugins included with Alpine: persist, intersect, collapse, and focus.
-
-### Lifecycle Hooks
-- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
-
-
-document.addEventListener('livewire:init', function () {
- Livewire.hook('request', ({ fail }) => {
- if (fail && fail.status === 419) {
- alert('Your session expired');
- }
- });
-
- Livewire.hook('message.failed', (message, component) => {
- console.error(message);
- });
-});
-
-
-
-=== volt/core rules ===
-
-## Livewire Volt
-
-- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt. There is documentation available for it.
-- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`
-- Volt is a **class-based** and **functional** API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to co-exist in the same file
-- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
-- You must check existing Volt components to determine if they're functional or class based. If you can't detect that, ask the user which they prefer before writing a Volt component.
-
-### Volt Functional Component Example
-
-
-@volt
- 0]);
-
-$increment = fn () => $this->count++;
-$decrement = fn () => $this->count--;
-
-$double = computed(fn () => $this->count * 2);
-?>
-
-
-
Count: {{ $count }}
-
Double: {{ $this->double }}
-
-
-
-@endvolt
-
-
-
-### Volt Class Based Component Example
-To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
-
-
-
-use Livewire\Volt\Component;
-
-new class extends Component {
- public $count = 0;
-
- public function increment()
- {
- $this->count++;
- }
-} ?>
-
-
-
{{ $count }}
-
-
-
-
-
-### Testing Volt & Volt Components
-- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
-
-
-use Livewire\Volt\Volt;
-
-test('counter increments', function () {
- Volt::test('counter')
- ->assertSee('Count: 0')
- ->call('increment')
- ->assertSee('Count: 1');
-});
-
-
-
-
-declare(strict_types=1);
-
-use App\Models\{User, Product};
-use Livewire\Volt\Volt;
-
-test('product form creates product', function () {
- $user = User::factory()->create();
-
- Volt::test('pages.products.create')
- ->actingAs($user)
- ->set('form.name', 'Test Product')
- ->set('form.description', 'Test Description')
- ->set('form.price', 99.99)
- ->call('create')
- ->assertHasNoErrors();
-
- expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
-});
-
-
-
-### Common Patterns
-
-
-
- null, 'search' => '']);
-
-$products = computed(fn() => Product::when($this->search,
- fn($q) => $q->where('name', 'like', "%{$this->search}%")
-)->get());
-
-$edit = fn(Product $product) => $this->editing = $product->id;
-$delete = fn(Product $product) => $product->delete();
-
-?>
-
-
-
-
-
-
-
-
-
-
- Save
- Saving...
-
-
-
-
-=== pint/core rules ===
-
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
-
-
-=== pest/core rules ===
-
-## Pest
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== pest/v4 rules ===
-
-## Pest 4
-
-- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
-- Browser testing is incredibly powerful and useful for this project.
-- Browser tests should live in `tests/Browser/`.
-- Use the `search-docs` tool for detailed guidance on utilizing these features.
-
-### Browser Testing
-- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
-- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
-- If requested, test on multiple browsers (Chrome, Firefox, Safari).
-- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
-- Switch color schemes (light/dark mode) when appropriate.
-- Take screenshots or pause tests for debugging when appropriate.
-
-### Example Tests
-
-
-it('may reset the password', function () {
- Notification::fake();
-
- $this->actingAs(User::factory()->create());
-
- $page = visit('/sign-in'); // Visit on a real browser...
-
- $page->assertSee('Sign In')
- ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
- ->click('Forgot Password?')
- ->fill('email', 'nuno@laravel.com')
- ->click('Send Reset Link')
- ->assertSee('We have emailed your password reset link!')
-
- Notification::assertSent(ResetPassword::class);
-});
-
-
-
-$pages = visit(['/', '/about', '/contact']);
-
-$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
-
-
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== tailwindcss/core rules ===
-## Tailwind Core
+## Tailwind CSS
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
Superior
+
Michigan
+
Erie
+
+
### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
=== tailwindcss/v4 rules ===
-## Tailwind 4
+## Tailwind CSS 4
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
@theme {
--color-brand: oklch(0.72 0.11 178);
@@ -590,9 +152,8 @@ $pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
+ @import "tailwindcss";
-
### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric.
| Deprecated | Replacement |
diff --git a/.gitignore b/.gitignore
index 016d612..859dd6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,8 @@ yarn-error.log
/.zed
laradumps.yaml
+.junie
+.history
+/temp
+/public/sitemap.xml
+_project
diff --git a/Commands.md b/Commands.md
new file mode 100644
index 0000000..4e332b2
--- /dev/null
+++ b/Commands.md
@@ -0,0 +1,86 @@
+## Development & Execution
+- `npm run dev` Running Vite for local development
+- `npm run build` Build assets for production
+- `php artisan serve` Start the Laravel development server
+- `php artisan tinker` Interact with your application in a REPL shell
+
+## Database & Migrations
+- `php artisan migrate` Run outstanding migrations
+- `php artisan make:migration [name]` Create a new migration file
+- `php artisan make:migration [name] --table=[table]` Create a migration to modify an existing table
+- `php artisan migrate:reset` Rollback all database migrations
+- `php artisan migrate:rollback` Rollback the last database migration
+- `php artisan migrate:fresh` Drop all tables and re-run all migrations
+- `php artisan migrate:refresh` Rollback and re-run all migrations
+- `php artisan migrate:refresh --seed --force` Rollback, re-run migrations, and seed the database in production
+- `php artisan make:seeder SeederName` Create a new seeder class
+- `php artisan db:seed` Seed the database with records
+
+## Generators (Controllers, Classes & Components)
+- `php artisan make:controller ControllerName` Create a new controller
+- `php artisan make:viewcomposer Name` Create a new view composer
+- `php artisan make:component [name] --inline` Create a new inline Blade component
+- `php artisan make:scope ProjectScope` Create a new Eloquent global scope
+- `php artisan make:trait` Create a new Trait
+- `php artisan make:command CustomTask` Create a new Artisan command
+- `php artisan make:class CustomClass` Create a new PHP class
+
+## Livewire
+- `php artisan make:livewire dir.name` Create a new Livewire component
+- `php artisan make:livewire dir.name --inline` Create a new inline Livewire component
+- `php artisan livewire:stubs` Publish Livewire stubs for customization
+- `php artisan livewire:layout` Create a new Livewire layout file
+
+## Optimization & Maintenance
+- `php artisan cache:clear` Flush the application cache
+- `php artisan view:clear` Clear all compiled view files
+- `php artisan config:clear` Remove the configuration cache file
+- `php artisan route:clear` Remove the route cache file
+- `composer dump-autoload` Regenerate the list of all classes that need to be included
+- `php artisan config:show myapp` Display the contents of a configuration file
+
+## Routing & Scheduling
+- `php artisan route:list` List all registered routes
+- `php artisan schedule:run` Run the scheduled commands
+- `php artisan schedule:work` Start the schedule worker (local development)
+- `php artisan schedule:list` List the scheduled tasks
+
+## Custom Application Commands (Imports & Sync)
+- `php artisan app:generate-album --url https://laravel-core.vades.dev/` Generate album from storage URL
+- `php artisan app:import-project-content` Import project contents from markdown files
+- `php artisan app:generate-sitemap laravel-core` Generate laravel-core.test sitemap
+- `php artisan app:generate-sitemap ivnbg --path=../domains/ivnbg.com/public_html/` Generate ivnbg.com sitemap
+# Preview files without downloading
+php artisan github:download-publish-files --dry-run
+
+# Download all files
+php artisan github:download-publish-files
+
+- `php artisan app:db-test` Run database connection/integrity test
+- `php artisan app:import-project-content` Import project content data
+- `php artisan app:generate-album --url [url]` Generate album from storage URL
+- `php artisan app:generate-sitemap ivnbg --path=../domains/ivnbg.com/public_html ` Generate sitemap for ivnbg.com
+- `php artisan app:generate-sitemap martinvach --path=../domains/martinvach.com/public_html ` Generate sitemap for martinvach.com
+- `php artisan app:generate-sitemap vades --path=../domains/vades.dev/public_html ` Generate sitemap for vades.dev
+-
+- `php artisan app:csv-to-md` Convert CSV files to Markdown
+- `php artisan app:sync-drive-to-local [path] [target]` Sync Google Drive folder to local
+- `php artisan app:google-drive-synchronize [folder] [target]` Synchronize Google Drive assets
+- `php artisan app:google-drive-download-files [src] [dest]` Download specific files from Google Drive
+
+
+## Model Generation Flags
+*Common flags used with `php artisan make:model ModelName`*
+
+- `-c`, `--controller` Create a new controller for the model
+- `-f`, `--factory` Create a new factory for the model
+- `--force` Create the class even if the model already exists
+- `-m`, `--migration` Create a new migration file for the model
+- `-s`, `--seed` Create a new seeder file for the model
+- `-p`, `--pivot` Indicates if the model should be a custom intermediate table model
+- `-r`, `--resource` Indicates if the controller should be a resource controller
+
+## Path Helpers
+- `base_path();` /var/www/mysite
+- `app_path();` /var/www/mysite/app
+- `storage_path();` /var/www/mysite/storage
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
new file mode 100644
index 0000000..03b6ff9
--- /dev/null
+++ b/DEPLOYMENT.md
@@ -0,0 +1,25 @@
+# Deployment Instructions
+This document provides step-by-step instructions for deploying the application to a production environment.
+## Create symbolic link for storage
+To ensure that images stored in the `storage` folder are accessible via the web, you need to create a symbolic link. Run the following command in your terminal:
+```bash
+php artisan storage:link
+```
+This command creates a symbolic link from `public/storage` to `storage/app/public`, allowing you to access images stored in the storage folder through your web server.
+### Verify the symbolic link
+After running the command, verify that the symbolic link was created successfully by checking the contents of the `public` directory:
+```bash
+ls -la public/
+```
+You should see a line indicating that `storage` is a symbolic link pointing to `../storage/app/public`.
+### Set correct permissions
+Ensure that the web server has the correct permissions to read from the `storage` folder. You may need to adjust the permissions using the following command:
+```bash
+chmod -R 775 storage
+```
+Make sure the web server user (e.g., `www-data` for Apache) has access to the `storage` directory.
+### Test image access
+To confirm that images can be accessed correctly, try to load an image stored in the `storage/app/public` directory via your web browser. The URL should look like this:
+```
+http://your-domain.com/storage/images/your-image.jpg
+```
\ No newline at end of file
diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 0000000..e25af94
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,523 @@
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.4.16
+- laravel/framework (LARAVEL) - v12
+- laravel/prompts (PROMPTS) - v0
+- laravel/telescope (TELESCOPE) - v5
+- livewire/livewire (LIVEWIRE) - v4
+- livewire/volt (VOLT) - v1
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- pestphp/pest (PEST) - v4
+- phpunit/phpunit (PHPUNIT) - v12
+- tailwindcss (TAILWINDCSS) - v4
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure; don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
+
+### Laravel 12 Structure
+- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
+- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== volt/core rules ===
+
+## Livewire Volt
+
+- This project uses Livewire Volt for interactivity within its pages. New pages requiring interactivity must also use Livewire Volt.
+- Make new Volt components using `php artisan make:volt [name] [--test] [--pest]`.
+- Volt is a class-based and functional API for Livewire that supports single-file components, allowing a component's PHP logic and Blade templates to coexist in the same file.
+- Livewire Volt allows PHP logic and Blade templates in one file. Components use the `@volt` directive.
+- You must check existing Volt components to determine if they're functional or class-based. If you can't detect that, ask the user which they prefer before writing a Volt component.
+
+### Volt Functional Component Example
+
+
+@volt
+ 0]);
+
+$increment = fn () => $this->count++;
+$decrement = fn () => $this->count--;
+
+$double = computed(fn () => $this->count * 2);
+?>
+
+
+
Count: {{ $count }}
+
Double: {{ $this->double }}
+
+
+
+@endvolt
+
+
+### Volt Class Based Component Example
+To get started, define an anonymous class that extends Livewire\Volt\Component. Within the class, you may utilize all of the features of Livewire using traditional Livewire syntax:
+
+
+use Livewire\Volt\Component;
+
+new class extends Component {
+ public $count = 0;
+
+ public function increment()
+ {
+ $this->count++;
+ }
+} ?>
+
+
+
{{ $count }}
+
+
+
+
+### Testing Volt & Volt Components
+- Use the existing directory for tests if it already exists. Otherwise, fallback to `tests/Feature/Volt`.
+
+
+use Livewire\Volt\Volt;
+
+test('counter increments', function () {
+ Volt::test('counter')
+ ->assertSee('Count: 0')
+ ->call('increment')
+ ->assertSee('Count: 1');
+});
+
+
+
+declare(strict_types=1);
+
+use App\Models\{User, Product};
+use Livewire\Volt\Volt;
+
+test('product form creates product', function () {
+ $user = User::factory()->create();
+
+ Volt::test('pages.products.create')
+ ->actingAs($user)
+ ->set('form.name', 'Test Product')
+ ->set('form.description', 'Test Description')
+ ->set('form.price', 99.99)
+ ->call('create')
+ ->assertHasNoErrors();
+
+ expect(Product::where('name', 'Test Product')->exists())->toBeTrue();
+});
+
+
+### Common Patterns
+
+
+ null, 'search' => '']);
+
+$products = computed(fn() => Product::when($this->search,
+ fn($q) => $q->where('name', 'like', "%{$this->search}%")
+)->get());
+
+$edit = fn(Product $product) => $this->editing = $product->id;
+$delete = fn(Product $product) => $product->delete();
+
+?>
+
+
+
+
+
+
+
+
+
+
+ Save
+ Saving...
+
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
+
+=== pest/core rules ===
+
+## Pest
+### Testing
+- If you need to verify a feature is working, write or update a Unit / Feature test.
+
+### Pest Tests
+- All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- Tests live in the `tests/Feature` and `tests/Unit` directories.
+- Pest tests look and behave like this:
+
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+
+
+### Running Tests
+- Run the minimal number of tests using an appropriate filter before finalizing code edits.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
+
+### Pest Assertions
+- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
+
+it('returns all', function () {
+ $response = $this->postJson('/api/docs', []);
+
+ $response->assertSuccessful();
+});
+
+
+### Mocking
+- Mocking can be very helpful when appropriate.
+- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
+- You can also create partial mocks using the same import or self method.
+
+### Datasets
+- Use datasets in Pest to simplify tests that have a lot of duplicated data. This is often the case when testing validation rules, so consider this solution when writing tests for validation rules.
+
+
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+
+
+=== pest/v4 rules ===
+
+## Pest 4
+
+- Pest 4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
+- Browser testing is incredibly powerful and useful for this project.
+- Browser tests should live in `tests/Browser/`.
+- Use the `search-docs` tool for detailed guidance on utilizing these features.
+
+### Browser Testing
+- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest 4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
+- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
+- If requested, test on multiple browsers (Chrome, Firefox, Safari).
+- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging when appropriate.
+
+### Example Tests
+
+
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in'); // Visit on a real browser...
+
+ $page->assertSee('Sign In')
+ ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!')
+
+ Notification::assertSent(ResetPassword::class);
+});
+
+
+
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
+
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/README.md b/README.md
index b8e26ad..d8df5e6 100644
--- a/README.md
+++ b/README.md
@@ -1,82 +1 @@
-# 3rd party libraries used in the app
-## laravel-google-drive-storage
-This package allow to store and get data from google drive like S3 AWS in laravel
-https://github.com/yaza-putu/laravel-google-drive-storage
-# Run app with docker
-```bash
-docker compose up -d
-```
-# Laravel quick commands
-- `npm run dev` Running Vite
-- `php artisan serve` Running Laravel
-- `npm run build` Build for production
-- `php artisan migrate` Run migrations
-- `php artisan make:controller ControllerName` Create a new controller
-- `php artisan route:list` List all routes
-- `php artisan make:viewcomposer ViewComposerName` Create a new view composer
-- `php artisan make:component web.features.blog --inline` Create a new component
-- `php artisan make:livewire dir.component-name` Create a new Livewire component
-- `php artisan make:livewire dir.component-name --inline` Create a new inline Livewire component
-- `php artisan livewire:stubs` Publish Livewire stubs
-- `php artisan livewire:layout` Create a new layout file
-- `php artisan make:migration create_flights_table` Create a new migration file
-- `php artisan make:migration add_status_to_flights_table --table=flights` Create a new migration file to modify an existing table
-- `php artisan make:seeder SeederName` Create a new middleware
-- `php artisan migrate:reset`
-- `php artisan migrate:rollback`
-- `php artisan migrate:fresh` Drop all tables and re-run all migrations
-- `php artisan migrate:refresh` Rollback and re-run all migrations
-- `php artisan migrate:refresh --seed --force` Rollback and re-run all migrations and seed the database
-- `php artisan db:seed`
-- `php artisan cache:clear`
-- `php artisan view:clear`
-- `php artisan config:clear`
-- `php artisan route:clear`
-- `composer dump-autoload`
-- `php artisan tinker`
-- `php artisan make:scope ProjectScope`
-- `php artisan make:trait`
-- `php artisan make:command CustomTask`
-- `php artisan make:class CustomClass`
-- `php artisan config:show myapp`
-- `php artisan schedule:run`
-- `php artisan schedule:work`
-- `php artisan schedule:list`
-
-## Imports
-IMPORTANT: USE `docker-compose exec larapi` php artisan app:db-test
-- php artisan app:import-project --name martinvach
-- php artisan app:generate-album --url https://www.ivnbg.com/storage/albums
-- php artisan app:csv-to-md
-- php artisan app:generate-ivnbg-sitemap
-- php artisan app:generate-martinvach-sitemap
-- php artisan app:sync-drive-to-local "KB/MyProjects/Larapi/imports" "app/imports"
-- php artisan app:google-drive-synchronize "KB/MyProjects/Larapi/imports" "app/imports"
-- php artisan app:google-drive-download-files "KB/MyProjects/ivnbg.com/content/posts/place" "app/imports/projects/ivnbg/posts/place"
-- php artisan app:google-drive-synchronize "albums" "storage/albums"
-
-```
--c, --controller Create a new controller for the model
--f, --factory Create a new factory for the model
---force Create the class even if the model already exists
--m, --migration Create a new migration file for the model
--s, --seed Create a new seeder file for the model
--p, --pivot Indicates if the generated model should be a custom intermediate table model
--r, --resource Indicates if the generated controller should be a resource controller
-For More Help
-php artisan make:model Todo -help
-```
-
-```bash
-php artisan cache:clear
-php artisan view:clear
-php artisan config:clear
-php artisan route:clear
-composer dump-autoload
-```
-
-```
- base_path(); // '/var/www/mysite'
- app_path(); // '/var/www/mysite/app'
- storage_path(); // '/var/www/mysite/storage'
-```
+# Laravel multi domain app
\ No newline at end of file
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
deleted file mode 100644
index aebff5e..0000000
--- a/app/Actions/Fortify/CreateNewUser.php
+++ /dev/null
@@ -1,39 +0,0 @@
- $input
- */
- public function create(array $input): User
- {
- Validator::make($input, [
- 'name' => ['required', 'string', 'max:255'],
- 'email' => [
- 'required',
- 'string',
- 'email',
- 'max:255',
- Rule::unique(User::class),
- ],
- 'password' => $this->passwordRules(),
- ])->validate();
-
- return User::create([
- 'name' => $input['name'],
- 'email' => $input['email'],
- 'password' => $input['password'],
- ]);
- }
-}
diff --git a/app/Actions/Fortify/PasswordValidationRules.php b/app/Actions/Fortify/PasswordValidationRules.php
deleted file mode 100644
index 76b19d3..0000000
--- a/app/Actions/Fortify/PasswordValidationRules.php
+++ /dev/null
@@ -1,18 +0,0 @@
-|string>
- */
- protected function passwordRules(): array
- {
- return ['required', 'string', Password::default(), 'confirmed'];
- }
-}
diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php
deleted file mode 100644
index 688d62f..0000000
--- a/app/Actions/Fortify/ResetUserPassword.php
+++ /dev/null
@@ -1,28 +0,0 @@
- $input
- */
- public function reset(User $user, array $input): void
- {
- Validator::make($input, [
- 'password' => $this->passwordRules(),
- ])->validate();
-
- $user->forceFill([
- 'password' => $input['password'],
- ])->save();
- }
-}
diff --git a/app/Console/Commands/DownloadGitHubPublishFiles.php b/app/Console/Commands/DownloadGitHubPublishFiles.php
new file mode 100644
index 0000000..de9b363
--- /dev/null
+++ b/app/Console/Commands/DownloadGitHubPublishFiles.php
@@ -0,0 +1,60 @@
+info('Fetching file list from GitHub…');
+
+ try {
+ $paths = DownloadPublishFiles::getFileList();
+ } catch (\Throwable $e) {
+ $this->error($e->getMessage());
+ return self::FAILURE;
+ }
+
+ $this->line(sprintf('Found %d files.', count($paths)));
+
+ if ($this->option('dry-run')) {
+ foreach ($paths as $path) {
+ $this->line(" $path");
+ }
+ return self::SUCCESS;
+ }
+
+ $this->info('Downloading…');
+ $bar = $this->output->createProgressBar(count($paths));
+ $bar->start();
+
+ $failed = [];
+
+ DownloadPublishFiles::downloadFiles($paths, function (string $path, $result) use ($bar, &$failed) {
+ if ($result !== true) {
+ $failed[$path] = $result;
+ }
+ $bar->advance();
+ });
+
+ $bar->finish();
+ $this->newLine(2);
+
+ if (!empty($failed)) {
+ $this->warn(count($failed) . ' file(s) failed:');
+ foreach ($failed as $path => $error) {
+ $this->line(" ✘> $path — $error");
+ }
+ return self::FAILURE;
+ }
+
+ $this->info('✔ All files saved to storage/app/imports.');
+ return self::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/app/Console/Commands/GenerateAlbum.php b/app/Console/Commands/GenerateAlbum.php
new file mode 100644
index 0000000..691bacf
--- /dev/null
+++ b/app/Console/Commands/GenerateAlbum.php
@@ -0,0 +1,57 @@
+option('url');
+ if(empty($url)) {
+ $url = config('myapp.album.url');
+ }
+ $this->info('Generating albums: ');
+ try {
+ $service = new AlbumGeneratorService($url);
+ $service->handle();
+
+ $errors = $service->getErrors();
+ foreach ($errors as $message) {
+ $this->error($message);
+ Log::error($message);
+ }
+
+ $success = $service->getSuccess();
+ foreach ($success as $message) {
+ $this->info($message);
+ Log::info($message);
+ }
+
+ }catch (Exception $e) {
+ $this->error($e->getMessage());
+ Log::error($e->getMessage());
+ }
+ }
+}
diff --git a/app/Console/Commands/GenerateSitemap.php b/app/Console/Commands/GenerateSitemap.php
new file mode 100644
index 0000000..c07d13f
--- /dev/null
+++ b/app/Console/Commands/GenerateSitemap.php
@@ -0,0 +1,60 @@
+argument('project');
+ $path = $this->option('path') ?? public_path();
+
+ logger()->info("Starting sitemap generation for {$project} | {$path}/sitemap.xml");
+
+ $this->info("🗺 Starting sitemap generation for {$project} | {$path}/sitemap.xml");
+ $this->newLine();
+
+ try {
+ $this->bootstrap($project);
+
+ $pages = $this->fetchContents(ContentContentType::Page);
+ $metaPages = $this->fetchContents(ContentContentType::Meta);
+ $articles = $this->fetchContents(ContentContentType::Article);
+ $places = $this->fetchContents(ContentContentType::Place);
+ $tutorials = $this->fetchContents(ContentContentType::Tutorial);
+ $guides = $this->fetchContents(ContentContentType::Guide);
+ $aiprompts = $this->fetchContents(ContentContentType::Aiprompt);
+
+ $this->logFetchedCounts(articles: $articles, pages: $pages, metaPages: $metaPages, places: $places,
+ tutorials: $tutorials, guides: $guides, aiprompts: $aiprompts);
+ $this->initProgressBar(articles: $articles, pages: $pages, metaPages: $metaPages, places: $places,
+ tutorials: $tutorials, guides: $guides, aiprompts: $aiprompts);
+
+ $this->addContents($pages, 'pages');
+ $this->addContents($metaPages);
+ $this->addContents($articles, 'blog');
+ $this->addContents($places, 'place');
+ $this->addContents($tutorials, 'tutorial');
+ $this->addContents($guides, 'guide');
+ $this->addContents($aiprompts, 'aiprompt');
+
+ $this->writeSitemap($this->option('path'));
+ $this->printSuccessSummary(articles: $articles, pages: $pages, metaPages: $metaPages, places: $places,
+ tutorials: $tutorials, guides: $guides, aiprompts: $aiprompts);
+
+ return self::SUCCESS;
+
+ } catch (Throwable $e) {
+ $this->printError($e);
+ report($e);
+
+ return self::FAILURE;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Console/Commands/ImportProjectContent.php b/app/Console/Commands/ImportProjectContent.php
new file mode 100644
index 0000000..2b9fa3e
--- /dev/null
+++ b/app/Console/Commands/ImportProjectContent.php
@@ -0,0 +1,57 @@
+info('Starting project content import...');
+ $this->newLine();
+
+ // Run the import logic
+ $service->handle();
+
+ // Output Successes
+ if (count($service->getSuccess()) > 0) {
+ $this->info('Successfully imported:');
+ foreach ($service->getSuccess() as $message) {
+ $this->line("- OK: {$message}");
+ }
+ }
+
+ $this->newLine();
+
+ // Output Errors
+ if (count($service->getErrors()) > 0) {
+ $this->error('Errors encountered during import:');
+ foreach ($service->getErrors() as $error) {
+ $this->line("- FAIL: {$error}");
+ }
+ }
+
+ $this->newLine();
+ $this->info('Import process completed.');
+
+ return Command::SUCCESS;
+ }
+}
\ No newline at end of file
diff --git a/app/Console/Commands/SitemapBase.php b/app/Console/Commands/SitemapBase.php
new file mode 100644
index 0000000..3d8ab02
--- /dev/null
+++ b/app/Console/Commands/SitemapBase.php
@@ -0,0 +1,142 @@
+baseUrl = $appProject->getUrl();
+ $this->domainManager->setSlug($appProject->value);
+ $this->projectId = $this->domainManager->getProjectId();
+
+ config(['app.project_id' => $this->projectId]);
+
+ $this->sitemap = Sitemap::create();
+ }
+
+ protected function initProgressBar(Collection ...$collections): void
+ {
+ $staticCount = 3; // home + about + contact
+ $total = $staticCount + array_sum(array_map(fn(Collection $c) => $c->count(), $collections));
+
+ $this->bar = $this->output->createProgressBar($total);
+ $this->bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% — %message%');
+ $this->bar->setMessage('Initialising...');
+ $this->bar->start();
+ }
+
+ protected function fetchContents(ContentContentType $contentType): Collection
+ {
+ return Content::withoutGlobalScopes()
+ ->where('project_id', $this->projectId)
+ ->publishedByType($contentType)
+ ->get();
+ }
+
+ protected function addContents(Collection $contents, string $routeName = null): void
+ {
+ foreach ($contents as $content) {
+ $urlPath = $routeName ? "{$routeName}/{$content->slug}" : $content->slug;
+ $this->bar->setMessage("Adding content: {$urlPath}");
+ $this->sitemap->add(
+ Url::create("{$this->baseUrl}/{$urlPath}")
+ ->setLastModificationDate(Carbon::yesterday())
+ );
+ $this->bar->advance();
+ }
+ }
+
+ protected function writeSitemap(string $publicPath = null): void
+ {
+ $path = $publicPath ?? public_path('sitemap.xml');
+
+ if ($publicPath) {
+ $path = rtrim($publicPath, '/') . '/sitemap.xml';
+ }
+
+ $this->bar->setMessage("Writing sitemap.xml to {$path}...");
+ $this->sitemap->writeToFile($path);
+ $this->bar->finish();
+ }
+
+ // ── Output helpers ───────────────────────────────────────────────────────
+
+ protected function logFetchedCounts(Collection ...$collections): void
+ {
+ $parts = array_map(
+ fn(Collection $collection, string $label) => "{$collection->count()} {$label}",
+ $collections,
+ array_keys($collections)
+ );
+
+ $this->line(' Found ' . implode(' and ', $parts) . '.');
+ $this->newLine();
+ }
+
+ protected function logFetchedCountsOld(Collection $categories, Collection $articles): void
+ {
+ $this->line(" Found {$categories->count()} categories and {$articles->count()} articles.");
+ $this->newLine();
+ }
+
+ protected function printSuccessSummary(Collection ...$collections): void
+ {
+ $total = 3 + array_sum(array_map(fn(Collection $c) => $c->count(), $collections));
+
+ $this->newLine(2);
+ $this->info('✅ Sitemap generated successfully → public/sitemap.xml');
+ $this->line(" Total URLs written: {$total}");
+ $this->newLine();
+
+ logger()->info('Sitemap generated successfully', [
+ 'total_urls' => $total
+ ]);
+ }
+
+ protected function printError(Throwable $e): void
+ {
+ $this->newLine(2);
+ $this->error('❌ Sitemap generation failed!');
+ $this->newLine();
+ $this->line(" Error:> {$e->getMessage()}");
+ $this->line(" File:> {$e->getFile()} (line {$e->getLine()})");
+ $this->newLine();
+
+ logger()->error('Sitemap generation failed', [
+ 'message' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ // 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Data/CategoryData.php b/app/Data/CategoryData.php
new file mode 100644
index 0000000..511c473
--- /dev/null
+++ b/app/Data/CategoryData.php
@@ -0,0 +1,46 @@
+ 'https://www.ivnbg.com',
+ self::MartinVach => 'https://www.martinvach.com',
+ self::MyPrompties => 'https://www.myprompties.com',
+ self::Vades => 'https://www.vades.dev',
+ self::Aitomatix => 'https://www.aitomatix.com',
+ self::AitomatixCz => 'https://www.aitomatix.cz',
+ self::LaravelCore => 'http://laravel-core.test',
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Enums/ContentContentType.php b/app/Enums/ContentContentType.php
new file mode 100644
index 0000000..54985c7
--- /dev/null
+++ b/app/Enums/ContentContentType.php
@@ -0,0 +1,31 @@
+ 'English',
+ self::ES => 'Spanish',
+ self::FR => 'French',
+ self::DE => 'German',
+ };
+ }
+}
\ No newline at end of file
diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php
new file mode 100644
index 0000000..6bf35cd
--- /dev/null
+++ b/app/Enums/UserRole.php
@@ -0,0 +1,12 @@
+ $query->meta(basename($request->path())),
+ 'contents' => $query->filtered(),
+ ]);
+ }
+
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(string $slug, AlbumQuery $album): View
+ {
+
+ $query = new ContentQuery(ContentContentType::Article);
+ $content = $query->findBySlug($slug, ['user']);
+
+ $next = $content->nextPublishedByType(ContentContentType::Article);
+ $previous = $content->previousPublishedByType(ContentContentType::Article);
+
+ $viewMode = $content['viewMode'] ?? 'default';
+
+ return view('article.show-' .$viewMode, [
+ 'page' => $content,
+ 'nextContent' => $next ? route('articleShow', ['slug' => $next->slug]) : null,
+ 'previousContent' => $previous ? route('articleShow', ['slug' => $previous->slug]) : null,
+ 'postImages' => $album->postImages($content),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Web/Default/HomeController.php b/app/Http/Controllers/Web/Default/HomeController.php
new file mode 100644
index 0000000..e720c1a
--- /dev/null
+++ b/app/Http/Controllers/Web/Default/HomeController.php
@@ -0,0 +1,30 @@
+ (new ContentQuery)->meta('home'),
+ 'placesFeatured' => $places->featured(take: 6),
+ 'places' => $places->latest(take: 12, excludeFeatured: true),
+ 'articles' => $articles->latest(take: 6),
+ 'images' => $album->homeImages(take: 6),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/Default/PageController.php b/app/Http/Controllers/Web/Default/PageController.php
new file mode 100644
index 0000000..4ecb6b6
--- /dev/null
+++ b/app/Http/Controllers/Web/Default/PageController.php
@@ -0,0 +1,22 @@
+ $query->findBySlug($slug),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Web/Default/PhotoGalleryController.php b/app/Http/Controllers/Web/Default/PhotoGalleryController.php
new file mode 100644
index 0000000..3ab5c32
--- /dev/null
+++ b/app/Http/Controllers/Web/Default/PhotoGalleryController.php
@@ -0,0 +1,44 @@
+path());
+
+ return view('photo-gallery.index', [
+ 'page' => (new ContentQuery)->meta($slug),
+ 'images' => $album->events(),
+ ]);
+ }
+
+ public function show(string $slug,DomainManagerService $domainManager,AlbumQuery $album): View
+ {
+ $meta = (new ContentQuery)->meta('photo-gallery');
+ $event = $album->eventByDirectory($slug);
+ debug($event);
+
+ if(!empty($event->title)){
+ $meta->title = $meta->title .' - ' .$event->title;
+ }
+
+ return view('photo-gallery.show', [
+ 'page' => $meta ,
+ 'images' => $album->imagesByDirectory($slug),
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/Web/Default/PlaceController.php b/app/Http/Controllers/Web/Default/PlaceController.php
new file mode 100644
index 0000000..93e3ea9
--- /dev/null
+++ b/app/Http/Controllers/Web/Default/PlaceController.php
@@ -0,0 +1,54 @@
+ $query->meta(basename($request->path())),
+ 'contents' => $query->filtered(),
+ ]
+ );
+ }
+
+
+ /**
+ * Display the specified resource.
+ */
+ public function show(string $slug, AlbumQuery $album): View
+ {
+ $query = new ContentQuery(ContentContentType::Place);
+ $content = $query->findBySlug($slug);
+
+ $next = $content->nextPublishedByType(ContentContentType::Place);
+ $previous = $content->previousPublishedByType(ContentContentType::Place);
+
+ $categorySlugs = $content->categories->pluck('slug')->toArray();
+ return view('place.show', [
+ 'page' => $content,
+ 'nextContent' => $next ? route('placeShow', ['slug' => $next->slug]) : null,
+ 'previousContent' => $previous ? route('placeShow', ['slug' => $previous->slug]) : null,
+ 'images' => $album->imagesByDirectory($slug),
+ 'highlights' => $query->byParentId(parentId:$content->id,take: 6, random: true),
+ 'related' => $query->setFilter('category',$categorySlugs)->filtered(),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/Default/TagController.php b/app/Http/Controllers/Web/Default/TagController.php
new file mode 100644
index 0000000..502c514
--- /dev/null
+++ b/app/Http/Controllers/Web/Default/TagController.php
@@ -0,0 +1,24 @@
+path());
+ $query = new TagQuery($contentType);
+ return view('tag.index', [
+ 'page' => (new ContentQuery)->meta('tags-'. $contentType),
+ 'tags' => $query->all(),
+ 'routeName' => $contentType . 'Index',
+ ]);
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/AbortWithNotFound.php b/app/Http/Middleware/AbortWithNotFound.php
new file mode 100644
index 0000000..97200a5
--- /dev/null
+++ b/app/Http/Middleware/AbortWithNotFound.php
@@ -0,0 +1,20 @@
+logout();
-
- Session::invalidate();
- Session::regenerateToken();
-
- return redirect('/');
- }
-}
diff --git a/app/Models/Category.php b/app/Models/Category.php
index 6a27116..b37fd51 100644
--- a/app/Models/Category.php
+++ b/app/Models/Category.php
@@ -2,10 +2,17 @@
namespace App\Models;
+use App\Enums\ContentContentType;
+use App\Enums\ContentStatus;
+use App\Enums\ContentVisibility;
+use App\Enums\Language;
+use App\Traits\FilterByProject;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Spatie\Sluggable\SlugOptions;
/**
* App\Models\Category
@@ -34,6 +41,7 @@ class Category extends Model
{
use HasFactory;
use SoftDeletes;
+ use FilterByProject;
/**
* The attributes that are mass assignable.
@@ -59,6 +67,10 @@ class Category extends Model
* @var array
*/
protected $casts = [
+ 'status' => ContentStatus::class,
+ 'content_type' => ContentContentType::class,
+ 'visibility' => ContentVisibility::class,
+ 'lang' => Language::class,
'metadata' => 'array',
'position' => 'integer',
];
@@ -78,4 +90,24 @@ public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
-}
+
+ public function contents()
+ {
+ return $this->belongsToMany(Content::class);
+ }
+
+ public function scopePublishedByType(Builder $query, null|string|ContentContentType $contentType = ContentContentType::Article->value): void
+ {
+ $value = $contentType instanceof ContentContentType ? $contentType->value : $contentType;
+
+ $query->where('status', ContentStatus::Published->value)
+ ->where('content_type', $value) ;
+ }
+ public function getSlugOptions() : SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('slug')
+ ->saveSlugsTo('slug')
+ ->doNotGenerateSlugsOnUpdate();
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Content.php b/app/Models/Content.php
index 4b663a8..1b4551b 100644
--- a/app/Models/Content.php
+++ b/app/Models/Content.php
@@ -2,10 +2,21 @@
namespace App\Models;
+use App\Enums\ContentContentType;
+use App\Enums\ContentStatus;
+use App\Enums\ContentVisibility;
+use App\Enums\Language;
+use App\Traits\FilterByProject;
+use App\Traits\HasDynamicContent;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Spatie\Sluggable\SlugOptions;
/**
* App\Models\Content
@@ -42,7 +53,8 @@ class Content extends Model
{
use HasFactory;
use SoftDeletes;
-
+ use FilterByProject;
+ use HasDynamicContent;
/**
* The attributes that are mass assignable.
*
@@ -50,6 +62,10 @@ class Content extends Model
*/
protected $fillable = [
'uuid',
+ 'project_id',
+ 'user_id',
+ 'author_id',
+ 'parent_id',
'content_type',
'status',
'visibility',
@@ -63,6 +79,7 @@ class Content extends Model
'position',
'is_featured',
'published_at',
+
];
/**
@@ -71,6 +88,10 @@ class Content extends Model
* @var array
*/
protected $casts = [
+ 'status' => ContentStatus::class,
+ 'content_type' => ContentContentType::class,
+ 'visibility' => ContentVisibility::class,
+ 'lang' => Language::class,
'metadata' => 'array',
'is_featured' => 'boolean',
'position' => 'integer',
@@ -108,4 +129,156 @@ public function parent(): BelongsTo
{
return $this->belongsTo(Content::class, 'parent_id');
}
+
+ public function categories()
+ {
+ return $this->belongsToMany(Category::class, 'category_content');
+ }
+
+ public function tags()
+ {
+ return $this->belongsToMany(Tag::class,'content_tag');
+ }
+
+ public function getSlugOptions() : SlugOptions
+ {
+ return SlugOptions::create()
+ ->generateSlugsFrom('slug')
+ ->saveSlugsTo('slug')
+ ->doNotGenerateSlugsOnUpdate();
+ }
+
+ /**
+ * Get rendered content.
+ */
+ protected function renderedContent(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => Str::of($this->content)->markdown(),
+ );
+ }
+
+ /**
+ * Get the cover image URL from metadata.
+ */
+ protected function coverImageUrl(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => !empty($this->metadata['coverImage']) ? config('myapp.image.domain').'/'.$this->metadata['coverImage'] : null,
+ );
+ }
+ /**
+ * Get the featured image URL from metadata.
+ */
+ protected function featuredImageUrl(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => !empty($this->metadata['featuredImage']) ? config('myapp.image.domain').'/'. $this->metadata['featuredImage'] : null,
+ );
+ }
+
+ /**
+ * Get the featured image URL from metadata.
+ */
+ protected function livewireWidget(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->metadata['livewireWidget'] ?? null,
+ );
+ }
+
+ /**
+ * Get the address from metadata.
+ */
+ protected function address(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->metadata['address'] ?? null,
+ );
+ }
+
+ /**
+ * Get the Google Map Embed URL from metadata.
+ */
+ protected function googleMapEmbedUrl(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->metadata['googleMapEmbedUrl'] ?? null,
+ );
+ }
+
+ /**
+ * Get the Meta Title from metadata.
+ */
+ protected function metaTitle(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->metadata['metaTitle'] ?? null,
+ );
+ }
+
+ /**
+ * Get the Meta Description from metadata.
+ */
+ protected function metaDescription(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->metadata['metaDescription'] ?? null,
+ );
+ }
+
+ public function scopePublished(Builder $query): void
+ {
+ $query->where('status', ContentStatus::Published->value);
+ }
+
+ public function scopePublishedByType(Builder $query, null|string|ContentContentType $contentType = ContentContentType::Article->value):
+ void
+ {
+ $value = $contentType instanceof ContentContentType ? $contentType->value : $contentType;
+ $query->where('status', ContentStatus::Published->value)
+ ->where('content_type', $value)
+ ;
+ }
+ public function scopeIsFeatured(Builder $query): void
+ {
+ $query->where('is_featured', 1);
+ }
+
+ public function scopeNotFeatured(Builder $query): void
+ {
+ $query->where('is_featured', 0);
+ }
+
+ public function scopeFilterByCategory(Builder $query, array|string $value): void
+ {
+ $query->whereHas('categories', fn($q) =>
+ $q->whereIn('slug', (array) $value)
+ );
+ }
+
+ public function scopeFilterByTag(Builder $query, array|string $value): void
+ {
+ $query->whereHas('tags', fn($q) =>
+ $q->whereIn('name', (array) $value)
+ );
+ }
+
+ public function nextPublishedByType(string|ContentContentType $contentType =ContentContentType::Article->value)
+ {
+ $value = $contentType instanceof ContentContentType ? $contentType->value : $contentType;
+ return $this->publishedByType( $value)
+ ->where('id', '>', $this->id)
+ ->orderBy('id')
+ ->first();
+ }
+
+ public function previousPublishedByType(string|ContentContentType $contentType = ContentContentType::Article->value)
+ {
+ $value = $contentType instanceof ContentContentType ? $contentType->value : $contentType;
+ return $this->publishedByType($contentType)
+ ->where('id', '<', $this->id)
+ ->orderByDesc('id')
+ ->first();
+ }
}
diff --git a/app/Models/ContentAnalytic.php b/app/Models/ContentAnalytic.php
new file mode 100644
index 0000000..176967a
--- /dev/null
+++ b/app/Models/ContentAnalytic.php
@@ -0,0 +1,53 @@
+
+ */
+ protected $fillable = [
+ 'views',
+ 'unique_views',
+ 'downloads',
+ 'last_viewed_at',
+ 'metadata',
+ ];
+
+ /**
+ * @var array
+ */
+ protected $casts = [
+ 'views' => 'int',
+ 'unique_views' => 'int',
+ 'downloads' => 'int',
+ 'last_viewed_at' => 'datetime',
+ 'metadata' => 'array',
+ ];
+
+ /**
+ * Get the content that owns the analytic.
+ */
+ public function content(): BelongsTo
+ {
+ return $this->belongsTo(Content::class);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Inquiry.php b/app/Models/Inquiry.php
index 074c590..22ba0e5 100644
--- a/app/Models/Inquiry.php
+++ b/app/Models/Inquiry.php
@@ -2,6 +2,8 @@
namespace App\Models;
+use App\Traits\FilterByProject;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -32,6 +34,8 @@
class Inquiry extends Model
{
use SoftDeletes;
+ use FilterByProject;
+ use HasFactory;
/**
* The attributes that are mass assignable.
@@ -39,6 +43,7 @@ class Inquiry extends Model
* @var array
*/
protected $fillable = [
+ 'project_id',
'is_read',
'is_spam',
'is_archived',
@@ -83,5 +88,4 @@ public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
-}
-
+}
\ No newline at end of file
diff --git a/app/Models/Project.php b/app/Models/Project.php
index 0947dc6..b6198d3 100644
--- a/app/Models/Project.php
+++ b/app/Models/Project.php
@@ -65,4 +65,4 @@ public function users()
{
return $this->hasMany(User::class);
}
-}
+}
\ No newline at end of file
diff --git a/app/Models/Tag.php b/app/Models/Tag.php
index a390a5f..60e9a33 100644
--- a/app/Models/Tag.php
+++ b/app/Models/Tag.php
@@ -2,6 +2,12 @@
namespace App\Models;
+use App\Enums\ContentContentType;
+use App\Enums\ContentStatus;
+use App\Enums\Language;
+use App\Traits\FilterByProject;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -25,6 +31,8 @@
class Tag extends Model
{
use SoftDeletes;
+ use FilterByProject;
+ use HasFactory;
/**
* The attributes that are mass assignable.
@@ -33,11 +41,13 @@ class Tag extends Model
*/
protected $fillable = [
'uuid',
+ 'content_type',
'is_published',
'tag_type',
'lang',
'views_count',
'name',
+ 'project_id',
];
/**
@@ -46,6 +56,7 @@ class Tag extends Model
* @var array
*/
protected $casts = [
+ 'lang' => Language::class,
'is_published' => 'bool',
'views_count' => 'int',
'created_at' => 'datetime',
@@ -60,5 +71,15 @@ public function project(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Project::class);
}
-}
+ public function contents()
+ {
+ return $this->belongsToMany(Content::class);
+ }
+
+ public function scopeByContentType(Builder $query, null|string|ContentContentType $contentType =ContentContentType::Article->value): void
+ {
+ $value = $contentType instanceof ContentContentType ? $contentType->value : $contentType;
+ $query->where('content_type',$value);
+ }
+}
\ No newline at end of file
diff --git a/app/Models/User.php b/app/Models/User.php
index 214bea4..a00d8b9 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -3,28 +3,36 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
+use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
-use Laravel\Fortify\TwoFactorAuthenticatable;
class User extends Authenticatable
{
- /** @use HasFactory<\Database\Factories\UserFactory> */
- use HasFactory, Notifiable, TwoFactorAuthenticatable;
+ /** @use HasFactory */
+ use HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list
*/
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var array
+ */
protected $fillable = [
'name',
'email',
'password',
+ 'project_id', // Ensure this is here
+ 'role', // Ensure this is here
+ 'account_type', // Ensure this is here
+ 'metadata', // Ensure this is here
];
-
/**
* The attributes that should be hidden for serialization.
*
@@ -47,6 +55,7 @@ protected function casts(): array
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
+ 'metadata' => 'array',
];
}
@@ -61,4 +70,4 @@ public function initials(): string
->map(fn ($word) => Str::substr($word, 0, 1))
->implode('');
}
-}
+}
\ No newline at end of file
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 452e6b6..2b7f969 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,9 @@
namespace App\Providers;
+use App\View\Composers\CategoryComposer;
+use App\View\Composers\TagComposer;
+use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,7 @@ public function register(): void
*/
public function boot(): void
{
- //
+ View::composer([ 'components.ui.my-categories-dropdown.index'], CategoryComposer::class);
+ //View::composer('*', TagComposer::class);
}
-}
+}
\ No newline at end of file
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
deleted file mode 100644
index 44e57aa..0000000
--- a/app/Providers/FortifyServiceProvider.php
+++ /dev/null
@@ -1,72 +0,0 @@
-configureActions();
- $this->configureViews();
- $this->configureRateLimiting();
- }
-
- /**
- * Configure Fortify actions.
- */
- private function configureActions(): void
- {
- Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
- Fortify::createUsersUsing(CreateNewUser::class);
- }
-
- /**
- * Configure Fortify views.
- */
- private function configureViews(): void
- {
- Fortify::loginView(fn () => view('livewire.auth.login'));
- Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
- Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
- Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
- Fortify::registerView(fn () => view('livewire.auth.register'));
- Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
- Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
- }
-
- /**
- * Configure rate limiting.
- */
- private function configureRateLimiting(): void
- {
- RateLimiter::for('two-factor', function (Request $request) {
- return Limit::perMinute(5)->by($request->session()->get('login.id'));
- });
-
- RateLimiter::for('login', function (Request $request) {
- $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
-
- return Limit::perMinute(5)->by($throttleKey);
- });
- }
-}
diff --git a/app/Providers/MultiDomainServiceProvider.php b/app/Providers/MultiDomainServiceProvider.php
new file mode 100644
index 0000000..e83e495
--- /dev/null
+++ b/app/Providers/MultiDomainServiceProvider.php
@@ -0,0 +1,157 @@
+registerDomainManagerService();
+ }
+
+ public function boot(DomainManagerService $domainManager): void
+ {
+ $this->initializePaths($domainManager);
+
+ $this->configureViewCascade();
+ $this->configureViteBuildDirectory();
+ $this->shareGlobalCssPath();
+ //$this->shareGlobalNavigation();
+
+ // Routing is disabled due to double-loading issues
+ // $this->configureRouting();
+ }
+
+ /**
+ * Register the DomainManagerService as a singleton
+ */
+ private function registerDomainManagerService(): void
+ {
+ $this->app->singleton(DomainManagerService::class, function ($app) {
+ return new DomainManagerService($app->request);
+ });
+ }
+
+ /**
+ * Initialize common paths used throughout the provider
+ */
+ private function initializePaths(DomainManagerService $domainManager): void
+ {
+ $this->slug = $domainManager->getSlug();
+ $this->siteViewPath = resource_path("views/components/{$this->slug}");
+ $this->defaultViewPath = resource_path("views/components/default");
+ }
+
+ /**
+ * Configure view cascade: site-specific → default → standard resources/views
+ */
+ private function configureViewCascade(): void
+ {
+ // Add site-specific views first (highest priority)
+ if (is_dir($this->siteViewPath)) {
+ View::share('globalViewPath', $this->siteViewPath);
+ View::getFinder()->prependLocation($this->siteViewPath);
+ }
+
+ // Add default views as fallback
+ if (is_dir($this->defaultViewPath)) {
+ View::share('globalViewPath', $this->defaultViewPath);
+ View::getFinder()->addLocation($this->defaultViewPath);
+ }
+ }
+
+ /**
+ * Configure Vite to use the appropriate build directory
+ */
+ private function configureViteBuildDirectory(): void
+ {
+ Vite::useBuildDirectory('build');
+ }
+
+ /**
+ * Share the appropriate CSS path with all views
+ */
+ private function shareGlobalCssPath(): void
+ {
+ $cssPath = $this->resolveCssPath();
+ View::share('globalCssPath', $cssPath);
+ }
+
+ /**
+ * Determine which CSS file to use (site-specific or default)
+ */
+ private function resolveCssPath(): string
+ {
+ $customCssPath = "resources/css/{$this->slug}/app.css";
+ $defaultCssPath = "resources/css/default/app.css";
+
+ return file_exists(resource_path("css/{$this->slug}/app.css"))
+ ? $customCssPath
+ : $defaultCssPath;
+ }
+
+ /**
+ * TODO: deprecated
+ * Share navigation data with all views
+ */
+ private function shareGlobalNavigation(): void
+ {
+ $navigation = $this->loadNavigationData();
+ View::share('globalNav', $navigation);
+ }
+
+ /**
+ * TODO: deprecated
+ * Load navigation data from site-specific or default location
+ */
+ private function loadNavigationData(): array
+ {
+ $siteNavPath = $this->siteViewPath . '/data/nav.php';
+ $defaultNavPath = $this->defaultViewPath . '/data/nav.php';
+
+ $navPath = file_exists($siteNavPath) ? $siteNavPath : $defaultNavPath;
+
+ return require_once $navPath;
+ }
+
+ /**
+ * Configure domain-specific routing
+ *
+ * TODO: Currently disabled - routes are loaded twice causing conflicts
+ * Need to implement proper route caching or registration guard
+ */
+ private function configureRouting(): void
+ {
+ // Prevent double-loading of routes
+ if (!app()->has('routes_loaded_by_multidomain')) {
+ $this->registerRoutes();
+ app()->instance('routes_loaded_by_multidomain', true);
+ }
+ }
+
+ /**
+ * Register routes from domain-specific file or fallback to web.php
+ */
+ private function registerRoutes(): void
+ {
+ $routeFile = base_path("routes/{$this->slug}.php");
+
+ if (file_exists($routeFile)) {
+ ds("Loading domain route file: {$routeFile}");
+ Route::middleware('web')->group($routeFile);
+ } else {
+ ds("Loading fallback route file: routes/web.php");
+ Route::middleware('web')->group(base_path('routes/web.php'));
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/Queries/AlbumQuery.php b/app/Queries/AlbumQuery.php
new file mode 100644
index 0000000..2787b35
--- /dev/null
+++ b/app/Queries/AlbumQuery.php
@@ -0,0 +1,61 @@
+domainManager->getSlug()))
+ ->shuffle()
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->values()
+ ->toArray()
+ ;
+ }
+
+ public function imagesByDirectory(string $directory, ?int $take = null,): array
+ {
+ return collect(AlbumService::getImages($this->domainManager->getSlug()))
+ ->where('directory', $directory)
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->values()
+ ->toArray();;
+ }
+
+ public function eventByDirectory(string $directory): \stdClass|null
+ {
+ return collect(AlbumService::getEvents($this->domainManager->getSlug()))
+ ->firstWhere('directory', $directory);
+ }
+
+ public function events(?int $take = null): array
+ {
+ return collect(AlbumService::getEvents($this->domainManager->getSlug()))
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->values()
+ ->toArray();;
+ }
+
+
+ public function postImages(Content $content): ?array
+ {
+ if (blank($content['eventDirectory'])) {
+ return null;
+ }
+ return null;
+
+ /* return collect(AlbumService::fetchPostImages())
+ ->where('directory', $content['eventDirectory'])
+ ->values()
+ ->toArray();*/
+ }
+}
\ No newline at end of file
diff --git a/app/Queries/CategoryQuery.php b/app/Queries/CategoryQuery.php
new file mode 100644
index 0000000..dd65b3f
--- /dev/null
+++ b/app/Queries/CategoryQuery.php
@@ -0,0 +1,36 @@
+remember(
+ cacheName: 'categories',
+ callback: fn() => Category::publishedByType($this->contentType)
+ ->whereHas('contents', function (Builder $subQuery) {
+ $subQuery->withoutGlobalScope('project_scope');
+ })
+ ->withCount(['contents' => function (Builder $subQuery) {
+ $subQuery->withoutGlobalScope('project_scope');
+ }])
+ ->get(),
+ contentType: $this->contentType,
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/Queries/ContentQuery.php b/app/Queries/ContentQuery.php
new file mode 100644
index 0000000..aaf19ca
--- /dev/null
+++ b/app/Queries/ContentQuery.php
@@ -0,0 +1,108 @@
+filters[$key] = $value;
+ return $this;
+ }
+
+ public function meta(string $slug): Content
+ {
+ return Content::publishedByType(ContentContentType::Meta)
+ ->where('slug', $slug)
+ ->firstOrFail()
+ ;
+ }
+
+ public function findBySlug(string $slug, array $with = []): Content
+ {
+ return Content::publishedByType($this->contentType)
+ ->where('slug', $slug)
+ ->when(!empty($with), fn($q) => $q->with($with))
+ ->firstOrFail()
+ ;
+ }
+
+ public function featured(?int $take = null, bool $random = false,): Collection
+ {
+ return Content::publishedByType($this->contentType)
+ ->isFeatured()
+ ->when($random, fn($q) => $q->inRandomOrder())
+ ->latest()
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->get()
+ ;
+ }
+
+ public function latest(?int $take = null, bool $random = false, bool $excludeFeatured = false): Collection
+ {
+ return Content::publishedByType($this->contentType)
+ ->when($excludeFeatured, fn($q) => $q->notFeatured())
+ ->when($random, fn($q) => $q->inRandomOrder())
+ ->latest()
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->get()
+ ;
+ }
+
+ public function byParentId(int $parentId, ?int $take = null, bool $random = false): Collection
+ {
+ return Content::publishedByType($this->contentType)
+ ->where('parent_id', $parentId)
+ ->when($random, fn($q) => $q->inRandomOrder())
+ ->latest()
+ ->when($take > 0, fn($q) => $q->take($take))
+ ->get()
+ ;
+ }
+
+ public function filtered(int $perPage = 20): LengthAwarePaginator|Collection
+ {
+ $query = QueryBuilder::for(Content::publishedByType($this->contentType))
+ ->allowedFilters(
+ AllowedFilter::scope('category', 'filterByCategory'),
+ AllowedFilter::scope('tag', 'filterByTag'),
+ )
+ ->allowedIncludes('categories', 'tags')
+ ->with(['categories', 'tags'])
+ ->when(
+ isset($this->filters['category']),
+ fn($q) => $q->filterByCategory(
+ is_array($this->filters['category'])
+ ? $this->filters['category']
+ : [$this->filters['category']]
+ )
+ )
+ ->when(
+ isset($this->filters['tag']),
+ fn($q) => $q->filterByTag(
+ is_array($this->filters['tag'])
+ ? $this->filters['tag']
+ : [$this->filters['tag']]
+ )
+ )
+ ->orderBy('created_at', 'desc')
+ ;
+
+ return $perPage > 0
+ ? $query->paginate($perPage)
+ : $query->get();
+ }
+}
\ No newline at end of file
diff --git a/app/Queries/TagQuery.php b/app/Queries/TagQuery.php
new file mode 100644
index 0000000..bb34f7a
--- /dev/null
+++ b/app/Queries/TagQuery.php
@@ -0,0 +1,36 @@
+remember(
+ cacheName: 'tags',
+ callback: fn() => Tag::byContentType($this->contentType)
+ ->whereHas('contents', function (Builder $subQuery) {
+ $subQuery->withoutGlobalScope('project_scope');
+ })
+ ->withCount(['contents' => function (Builder $subQuery) {
+ $subQuery->withoutGlobalScope('project_scope');
+ }])
+ ->get(),
+ contentType: $this->contentType,
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Album/AlbumDataResource.php b/app/Services/Album/AlbumDataResource.php
new file mode 100644
index 0000000..8f0a3fe
--- /dev/null
+++ b/app/Services/Album/AlbumDataResource.php
@@ -0,0 +1,13 @@
+ 0,
+ ];
+}
\ No newline at end of file
diff --git a/app/Services/Album/AlbumGeneratorService.php b/app/Services/Album/AlbumGeneratorService.php
new file mode 100644
index 0000000..78960db
--- /dev/null
+++ b/app/Services/Album/AlbumGeneratorService.php
@@ -0,0 +1,376 @@
+errors;
+ }
+
+ private array $success = [];
+
+ public function getSuccess(): array
+ {
+ return $this->success;
+ }
+
+
+ public function __construct(string $url)
+ {
+ $this->sourceDir = config('myapp.album.dir.source');
+ $this->targetDir = config('myapp.album.dir.target');
+ $this->url = $url;
+ $this->albums = new AlbumDataResource();
+
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function handle(): void
+ {
+
+ $this->readAlbumDir($this->sourceDir);
+ $this->readEventDir($this->sourceDir);
+
+
+ }
+
+ private function readAlbumDir(string $sourceDir): void
+ {
+ $directories = array_filter(glob($sourceDir . '/*'), 'is_dir');
+ if (empty($directories)) {
+ throw new Exception('No album directories found in: ' . $this->sourceDir);
+ }
+
+ $directories = array_map('basename', $directories);
+ $this->albums->data = $this->parseCoverFiles($directories, $sourceDir);
+ $filePath = config('myapp.album.dir.target') . '/' . config('myapp.album.file.albums');
+ $this->storeJsonFile($this->albums, $filePath);
+ //dd( 'done readAlbumDir');
+ }
+
+ private function readEventDir($sourceDir): void
+ {
+ $events = [];
+
+ foreach ($this->albums->data as $album) {
+ $directories = array_filter(glob($sourceDir . '/' . $album['id'] . '/*'), 'is_dir');
+
+
+ if (empty($directories)) {
+ throw new Exception('No event directories found in: ' . $this->sourceDir);
+ }
+
+ $directories = array_map('basename', $directories);
+ $event = new AlbumDataResource();
+ $event->data = $this->parseCoverFiles($directories, $sourceDir . '/' . $album['id'], $album['id']);
+ $this->events[$album['id']] = $event;
+ }
+
+ foreach ($this->events as $album => $eventList) {
+ $targetFilePath = config('myapp.album.dir.target') . '/' .$album. '/'.config('myapp.album.file.events');
+ $this->storeJsonFile($eventList, $targetFilePath);
+ $this->readImageDir($this->sourceDir, $eventList->data);
+ }
+
+ }
+
+ private function readImageDir($sourceDir, $eventList): void
+ {
+ foreach ($eventList as $event) {
+ $srcDir = $sourceDir . '/' . $event['id'] . '/' . config('myapp.album.srcDir');
+ $thumbDir = $sourceDir . '/' . $event['id'] . '/' . config('myapp.album.thumbDir');
+ if (!is_dir($srcDir)) {
+ throw new Exception('No image directories found in: ' . $this->sourceDir);
+ }
+ $imageFiles = glob($srcDir . '/*.{jpg,gif,png}', GLOB_BRACE);
+ if (empty($imageFiles)) {
+ throw new Exception('No images found in: ' . $srcDir);
+ }
+
+
+ foreach ($imageFiles as $imageFile) {
+ $this->parseImageFile($imageFile, $event, $thumbDir);
+ }
+ }
+
+ $targetFilePath = config('myapp.album.dir.target') . '/' . config('myapp.album.file.images');
+
+ foreach ($this->images as $album => $imageList) {
+ $targetFilePath = config('myapp.album.dir.target') . '/' .$album. '/'.config('myapp.album.file.images');
+ $images = new AlbumDataResource();
+ $images->data =$imageList;
+ //dd($images);
+ $this->storeJsonFile($images, $targetFilePath);
+ }
+
+
+
+ }
+
+ private function parseCoverFiles(array $directories, string $sourceDir, ?string $parentDir = null): array
+ {
+
+ $items = [];
+ foreach ($directories as $directory) {
+ $path = $sourceDir . '/' . $directory;
+
+ $coverPath = $path . '/' . config('myapp.album.cover');
+ if (!file_exists($coverPath)) {
+ $this->errors[] = 'WARNING: No cover found in directory: ' . $path;
+ continue;
+ }
+
+ if (file_exists($coverPath) && @getimagesize($coverPath, $imageData)) {
+ $parentPath = $parentDir ? $parentDir . '/' : '';
+ $cover = $this->url . '/' . $parentPath.$directory . '/' . config('myapp.album.cover');
+ // $iptc = $this->getIptcData($imageData); // Remove this line
+ } else {
+ $this->errors[] = 'WARNING: Invalid cover image found in directory: ' . $path;
+ continue;
+ }
+
+ $featuredPath = $path . '/' . config('myapp.album.featured');
+ if (!file_exists($featuredPath)) {
+ $this->errors[] = 'WARNING: No featured found in directory: ' . $path;
+ }
+ $featured = null;
+ if (file_exists($featuredPath) && @getimagesize($featuredPath, $featuredData)) {
+ $parentPath = $parentDir ? $parentDir . '/' : '';
+ $featured = $this->url . '/' . $parentPath.$directory . '/' . config('myapp.album.featured');
+ } else {
+ $this->errors[] = 'WARNING: Invalid featured image found in directory: ' . $path;
+ }
+ $options = [
+ 'id' => ($parentDir ? $parentDir . '/' : '') . $directory,
+ 'directory' => $directory,
+ 'parentId' => $parentDir ?? null,
+ 'src' => $cover,
+ 'featured' => $featured,
+ 'thumbnail' => null,
+ 'iptc' => $this->getIptcData($coverPath), // Pass image path
+ ];
+ $items[] = $this->parseAlbumImage($options);
+
+
+ }
+
+ return $items;
+ }
+
+ private function parseImageFile(string $imageFile, array $event, string $thumbDir): void
+ {
+ $imageData = [];
+ if (!file_exists($imageFile) && !@getimagesize($imageFile, $imageData)) {
+ $this->errors[] = 'WARNING: Invalid image found: ' . $imageFile;
+ }
+
+
+
+ $fileName = basename($imageFile);
+ $imagePath = $event['id'] . '/' . config('myapp.album.srcDir') . '/' . $fileName;
+ $thumUrl = $this->url . '/' . $event['id'] . '/' . config('myapp.album.thumbDir') . '/' . $fileName;
+ if (!is_dir($thumbDir)) {
+ mkdir($thumbDir, 0777, true);
+ }
+ $thumbPath = $thumbDir . '/' . $fileName;
+
+ if (!file_exists($thumbPath)) {
+ $this->generateThumbnail($imageFile, $thumbPath, config('myapp.album.thumbWidth'));
+ }
+ $options = [
+ 'id' => $imagePath,
+ 'directory' => $event['directory'],
+ 'parentId' => $event['id'],
+ 'src' => $this->url . '/' . $imagePath,
+ 'thumbnail' => $thumUrl,
+ 'iptc' => $this->getIptcData($imageFile), // Pass image path
+ //'exif' => @exif_read_data($imageFile, 'ANY_TAG', true),
+ ];
+
+
+ $this->images[ $event['parentId']][] = $this->parseAlbumImage($options);
+
+
+
+
+ }
+ private function parseAlbumImage(array $options): array
+ {
+ return [
+ 'id' => $options['id'],
+ 'directory' => $options['directory'],
+ 'parentId' => $options['parentId'],
+ 'src' => $options['src'],
+ 'thumbnail' => $options['thumbnail'] ?? null,
+ 'featured' => $options['featured'] ?? null,
+ 'title' => $options['iptc']['title'] ?? $options['directory'],
+ 'createdAt' => new Carbon($options['iptc']['date'] ?? null),
+ 'description' => $options['iptc']['description'] ?? null,
+
+ 'author' => $options['iptc']['author'] ?? null,
+ //'tags' => $options['iptc']['tags'] ?? null,
+ // 'exif' => $options['exif'] ?? null,
+
+
+ ];
+
+ }
+
+ // Refactor getIptcData to accept image path and extract IPTC data inside
+ private function getIptcData($imagePath): array
+ {
+ $return = array('title' => null, 'description' => null, 'author' => null, 'tags' => null, 'date' => null);
+ if (!file_exists($imagePath)) {
+ return $return;
+ }
+ $info = [];
+ @getimagesize($imagePath, $info);
+ if (isset($info['APP13'])) {
+ $iptc = iptcparse($info['APP13']);
+ //dump( $imagePath.': '.($iptc ?? 'No IPTC data'));
+ // Debug: log IPTC fields for troubleshooting
+ if (!isset($iptc['2#120'][0])) {
+ /*$this->errors[] = 'DEBUG: No IPTC caption (2#120) found for image: ' . $imagePath . ' IPTC: ' . print_r($iptc, true);*/
+ //$this->errors[] = 'DEBUG: No IPTC caption (2#120) found for image: ' . $imagePath ;
+ }
+ $return['title'] = isset($iptc['2#005'][0]) ? html_entity_decode($iptc['2#005'][0], ENT_QUOTES | ENT_XML1, 'UTF-8') : null;
+ // Lightroom "caption" is stored in 2#120, which is mapped to description
+ if (isset($iptc['2#120'][0])) {
+ //dump( '2#120: '.$iptc['2#120'][0]);
+ $return['description'] = html_entity_decode($iptc['2#120'][0], ENT_QUOTES | ENT_XML1, 'UTF-8');
+ } elseif (isset($iptc['2#122'][0])) {
+ dump( '2#122');
+ // Fallback to Writer/Editor if caption is missing
+ $return['description'] = html_entity_decode($iptc['2#122'][0], ENT_QUOTES | ENT_XML1, 'UTF-8');
+ } elseif (isset($iptc['2#005'][0])) {
+ //dump( '2#005: ' . $iptc['2#005'][0]);
+ // Fallback to Object Name if both are missing
+ $return['description'] = html_entity_decode($iptc['2#005'][0], ENT_QUOTES | ENT_XML1, 'UTF-8');
+ } else {
+ $return['description'] = null;
+ }
+ //dump( 'Description for '.$imagePath.': '.$return['description']);
+ $return['author'] = isset($iptc['2#080'][0]) ? html_entity_decode($iptc['2#080'][0], ENT_QUOTES | ENT_XML1, 'UTF-8') : null;
+ $return['tags'] = isset($iptc['2#025']) ? array_map(function($tag) {
+ return html_entity_decode($tag, ENT_QUOTES | ENT_XML1, 'UTF-8');
+ }, $iptc['2#025']) : null;
+ $return['date'] = isset($iptc['2#062'][0]) ? html_entity_decode($iptc['2#062'][0], ENT_QUOTES | ENT_XML1, 'UTF-8') : null;
+ } else {
+ //$this->errors[] = 'DEBUG: No IPTC APP13 found for image: ' . $imagePath;
+ }
+ return $return;
+ }
+
+ private function generateThumbnail(string $imageFile, string $thumbPath, int $thumbWidth = 150): void
+ {
+ $imageInfo = getimagesize($imageFile);
+ if ($imageInfo === false) {
+ $this->errors[] = 'ERROR: Failed to get image size for: ' . $imageFile;
+ return;
+ }
+
+ list($width, $height) = $imageInfo;
+ $thumbHeight = intval($height * $thumbWidth / $width);
+
+ $thumbnail = imagecreatetruecolor($thumbWidth, $thumbHeight);
+
+ switch ($imageInfo['mime']) {
+ case 'image/jpeg':
+ $source = imagecreatefromjpeg($imageFile);
+ break;
+ case 'image/png':
+ $source = imagecreatefrompng($imageFile);
+ break;
+ case 'image/gif':
+ $source = imagecreatefromgif($imageFile);
+ break;
+ default:
+ $this->errors[] = 'ERROR: Unsupported image type for: ' . $imageFile;
+ return;
+ }
+
+ imagecopyresampled($thumbnail, $source, 0, 0, 0, 0, $thumbWidth, $thumbHeight, $width, $height);
+
+ switch ($imageInfo['mime']) {
+ case 'image/jpeg':
+ imagejpeg($thumbnail, $thumbPath);
+ break;
+ case 'image/png':
+ imagepng($thumbnail, $thumbPath);
+ break;
+ case 'image/gif':
+ imagegif($thumbnail, $thumbPath);
+ break;
+ }
+
+ imagedestroy($source);
+ imagedestroy($thumbnail);
+
+ }
+
+ private function storeJsonFile($dataResource, $targetFilePath): void
+ {
+
+
+ if (count($dataResource->data) < 1) {
+ $this->errors[] = 'ERROR: No data to store in JSON file: ' . $targetFilePath;
+ return;
+ }
+
+
+ $dataResource->createdAt = date('Y-m-d H:i:s');
+ $dataResource->message = 'OK 200';
+ $dataResource->meta['total'] = count($dataResource->data);
+
+ $json = json_encode($dataResource, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES| JSON_UNESCAPED_UNICODE);
+ if ($json === false) {
+ $this->errors[] = 'ERROR: Failed to generate JSON from albums array.';
+ return;
+ }
+ if (@file_put_contents($targetFilePath, $json) === false) {
+ $this->errors[] = 'ERROR: Failed to save JSON to file: ' . $targetFilePath;
+ return;
+ }
+ $this->success[] = 'SUCCESS: JSON file saved successfully: ' . $targetFilePath;
+
+
+ }
+
+ private function readExifData(string $coverPath): array
+ {
+ $exifData = @exif_read_data($coverPath, 'ANY_TAG', true);
+ // Additional information from Lightroom
+ getimagesize($coverPath, $infos);
+ if (isset($infos['APP13'])) {
+ print_r(iptcparse($infos['APP13']));
+ }
+ if ($exifData === false) {
+ $this->errors[] = 'ERROR: Failed to read EXIF data from image: ' . $coverPath;
+ return ['title' => null, 'description' => null];
+ }
+
+ $title = $exifData['ImageDescription'] ?? null;
+ $description = $exifData['UserComment'] ?? null;
+
+ return ['title' => $title, 'description' => $description];
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Album/AlbumService.php b/app/Services/Album/AlbumService.php
new file mode 100644
index 0000000..f7d4d7e
--- /dev/null
+++ b/app/Services/Album/AlbumService.php
@@ -0,0 +1,49 @@
+data ?? [];
+ } catch (\Throwable $e) {
+ Log::error("Error reading file {$fullPath}: " . $e->getMessage());
+ return [];
+ }
+ }
+
+ public static function getAlbums(): array
+ {
+ return self::getData(config('myapp.album.file.albums'));
+ }
+
+ public static function getEvents(string $event): array
+ {
+ return self::getData($event .'/'.config('myapp.album.file.events'));
+ }
+
+ public static function getImages(string $event): array
+ {
+ return self::getData($event .'/'.config('myapp.album.file.images'));
+ }
+
+
+}
\ No newline at end of file
diff --git a/app/Services/Cache/CacheService.php b/app/Services/Cache/CacheService.php
new file mode 100644
index 0000000..a1f731e
--- /dev/null
+++ b/app/Services/Cache/CacheService.php
@@ -0,0 +1,148 @@
+normalizeDomain();
+
+ if (!empty($contentType)) {
+ $normalizedType = strtolower(preg_replace('/[^a-z0-9]+/i', '_', $contentType));
+ $cacheName .= '_' . trim($normalizedType, '_');
+ }
+
+ $hash = substr(md5($normalizedDomain . ':' . $cacheName), 0, 8);
+
+ return "{$normalizedDomain}:{$cacheName}:{$hash}";
+ }
+
+ /**
+ * Store a value in the cache under the generated key.
+ *
+ * @param string $cacheName
+ * @param mixed $value Data to cache
+ * @param string|null $contentType
+ * @param int|null $duration TTL in seconds (null = config default)
+ * @return string The cache key that was used
+ */
+ public function put(
+ string $cacheName,
+ mixed $value,
+ ?string $contentType = null,
+ ?int $duration = null
+ ): string {
+ $key = $this->generateCacheName($cacheName, $contentType);
+
+ Cache::put($key, $value, $duration ?? $this->defaultDuration());
+
+ return $key;
+ }
+
+ /**
+ * Retrieve a cached value by cache name and optional content type.
+ *
+ * @param string $cacheName
+ * @param string|null $contentType
+ * @param mixed $default Returned when the key is not found
+ * @return mixed
+ */
+ public function get(string $cacheName, ?string $contentType = null, mixed $default = null): mixed
+ {
+ $key = $this->generateCacheName($cacheName, $contentType);
+
+ return Cache::get($key, $default);
+ }
+
+ /**
+ * Retrieve a cached value directly by its cache key name.
+ *
+ * @param string $cacheName Key previously returned by generateCacheName() or put()
+ * @param mixed $default
+ * @return mixed
+ */
+ public function getByName(string $cacheName, mixed $default = null): mixed
+ {
+ return Cache::get($cacheName, $default);
+ }
+
+ /**
+ * Check whether a cache entry exists.
+ */
+ public function has(string $cacheName, ?string $contentType = null): bool
+ {
+ return Cache::has($this->generateCacheName($cacheName, $contentType));
+ }
+
+ /**
+ * Remove a specific cache entry.
+ */
+ public function forget(string $cacheName, ?string $contentType = null): bool
+ {
+ return Cache::forget($this->generateCacheName($cacheName, $contentType));
+ }
+
+ /**
+ * Retrieve from cache or execute the callback and store the result.
+ *
+ * @param string $cacheName
+ * @param \Closure $callback Produces the value when cache misses
+ * @param string|null $contentType
+ * @param int|null $duration TTL in seconds (null = config default)
+ * @return mixed
+ */
+ public function remember(
+ string $cacheName,
+ \Closure $callback,
+ ?string $contentType = null,
+ ?int $duration = null
+ ): mixed {
+ $key = $this->generateCacheName($cacheName, $contentType);
+
+ return Cache::remember($key, $duration ?? $this->defaultDuration(), $callback);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Resolve the default TTL from config/cache_service.php.
+ * Falls back to 86400 (1 day) if the config key is missing.
+ */
+ private function defaultDuration(): int
+ {
+ return (int) config('myapp.cacheDuration', 86400);
+ }
+
+ /**
+ * Resolve and normalize the domain from the current HTTP request host.
+ * Falls back to 'cli' when running outside of an HTTP context (e.g. Artisan).
+ *
+ * Examples:
+ * "example.com" → "example_com"
+ * "api.example.com" → "api_example_com"
+ * "localhost:8000" → "localhost_8000"
+ */
+ private function normalizeDomain(): string
+ {
+ $host = Request::getHost() ?: 'cli';
+
+ $host = strtolower($host);
+ $host = preg_replace('/[^a-z0-9]+/', '_', $host);
+
+ return trim($host, '_');
+ }
+}
\ No newline at end of file
diff --git a/app/Services/DomainManagerService.php b/app/Services/DomainManagerService.php
new file mode 100644
index 0000000..0e5a381
--- /dev/null
+++ b/app/Services/DomainManagerService.php
@@ -0,0 +1,223 @@
+initializeFromRequest($request);
+ }
+
+ /**
+ * Initialize the service from the incoming request
+ */
+ private function initializeFromRequest(Request $request): void
+ {
+ $this->currentHost = $request->getHost();
+
+ $this->slug = $this->determineSlug();
+ $this->projectId = $this->resolveProjectId($this->slug);
+
+ $this->updateApplicationConfig();
+ }
+
+ /**
+ * Determine the appropriate slug from environment or host
+ */
+ private function determineSlug(): string
+ {
+ // Environment variable takes precedence
+ $envSlug = env('MY_PROJECT_SLUG');
+ if (!empty($envSlug)) {
+ return $envSlug;
+ }
+
+ // Otherwise, detect from host
+ return $this->detectSlugFromHost();
+ }
+
+ /**
+ * Extract slug from the current host
+ * Logic: ivnbg.com -> ivnbg, www.ivnbg.com -> ivnbg
+ */
+ private function detectSlugFromHost(): string
+ {
+ if ($this->isLocalOrEmptyHost()) {
+ return self::DEFAULT_SLUG;
+ }
+
+ $normalizedHost = $this->normalizeHost($this->currentHost);
+
+ return $this->extractSlugFromHost($normalizedHost);
+ }
+
+ /**
+ * Check if running in CLI mode or localhost
+ */
+ private function isLocalOrEmptyHost(): bool
+ {
+ return empty($this->currentHost) || $this->currentHost === 'localhost';
+ }
+
+ /**
+ * Normalize host by removing www prefix
+ */
+ private function normalizeHost(string $host): string
+ {
+ return Str::replace('www.', '', $host);
+ }
+
+ /**
+ * Extract the slug from the host
+ * Root domain: removes extension and prefixes (e.g., domain-name.com -> domain-name)
+ * Subdomain: returns subdomain only (e.g., subdomain-name.domain.com -> subdomain-name)
+ */
+ private function extractSlugFromHost(string $host): string
+ {
+ if (empty($host)) {
+ return self::DEFAULT_SLUG;
+ }
+
+ return explode('.', $host)[0];
+ }
+
+ /**
+ * Resolve the project ID from the database using the slug
+ */
+ private function resolveProjectId(string $slug): ?int
+ {
+ $cacheKey = $this->getCacheKey($slug);
+$cache = new CacheService();
+ return $cache->remember(
+ cacheName: $cacheKey,
+ callback: fn() => $this->fetchProjectIdFromDatabase($slug) ,
+ contentType: $slug,
+ );
+ }
+
+ /**
+ * Fetch project ID from the database
+ */
+ private function fetchProjectIdFromDatabase(string $slug): ?int
+ {
+ try {
+ return Project::where('slug', $slug)->value('id');
+ } catch (QueryException $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Generate cache key for a given slug
+ */
+ private function getCacheKey(string $slug): string
+ {
+ return self::CACHE_KEY_PREFIX . $slug;
+ }
+
+ /**
+ * Update application configuration with the current slug
+ */
+ private function updateApplicationConfig(): void
+ {
+ Config::set('myapp.projectSlug', $this->slug);
+
+
+ // Load project-specific config file
+ $this->loadProjectConfig($this->slug);
+ }
+
+ private function loadProjectConfig(string $slug): void
+ {
+ $path = base_path("config/projects/{$slug}.php");
+
+ if (!file_exists($path)) {
+ return;
+ }
+
+ $projectConfig = require $path;
+
+ // Merge project config into myapp config
+ $current = Config::get('myapp', []);
+ Config::set('myapp', array_replace_recursive($current, $projectConfig));
+ }
+
+ /**
+ * Get the current slug
+ */
+ public function getSlug(): string
+ {
+ return $this->slug;
+ }
+
+ /**
+ * Get the current project ID
+ */
+ public function getProjectId(): ?int
+ {
+ return $this->projectId;
+ }
+
+ /**
+ * Manually set the slug (e.g., from an Artisan command)
+ * This automatically re-resolves the project ID and updates config
+ */
+ public function setSlug(string $slug): self
+ {
+ $this->slug = $slug;
+ $this->projectId = $this->resolveProjectId($slug);
+ $this->updateApplicationConfig();
+
+ return $this;
+ }
+
+ /**
+ * Manually override the project ID if needed
+ */
+ public function setProjectId(int $projectId): self
+ {
+ $this->projectId = $projectId;
+
+ return $this;
+ }
+
+ /**
+ * Clear the cached project ID for a specific slug
+ */
+ public function clearCache(?string $slug = null): void
+ {
+ $targetSlug = $slug ?? $this->slug;
+ $cacheKey = $this->getCacheKey($targetSlug);
+
+ Cache::forget($cacheKey);
+ }
+
+ /**
+ * Refresh the project ID from the database
+ */
+ public function refresh(): self
+ {
+ $this->clearCache();
+ $this->projectId = $this->resolveProjectId($this->slug);
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/app/Services/GitHub/DownloadPublishFiles.php b/app/Services/GitHub/DownloadPublishFiles.php
new file mode 100644
index 0000000..f2a7139
--- /dev/null
+++ b/app/Services/GitHub/DownloadPublishFiles.php
@@ -0,0 +1,82 @@
+ 'application/vnd.github+json',
+ 'User-Agent' => 'Laravel',
+ ])->get($url);
+
+ if ($response->status() === 404) {
+ throw new \RuntimeException(
+ sprintf(
+ 'Repository "%s/%s" not found or branch "%s" does not exist.',
+ self::OWNER, self::REPO, self::BRANCH
+ )
+ );
+ }
+
+ if ($response->failed()) {
+ throw new \RuntimeException(
+ 'GitHub API error [' . $response->status() . ']: ' . $response->body()
+ );
+ }
+
+ return collect($response->json('tree', []))
+ ->where('type', 'blob')
+ ->pluck('path')
+ ->values()
+ ->all();
+ }
+
+ public static function downloadFiles(array $paths, callable $onFile = null): array
+ {
+ $results = [];
+
+ foreach ($paths as $path) {
+ try {
+ $url = sprintf(
+ 'https://raw.githubusercontent.com/%s/%s/%s/%s',
+ self::OWNER, self::REPO, self::BRANCH, $path
+ );
+
+ $content = Http::get($url)->throw()->body();
+
+ $destination = storage_path('app/imports/' . $path);
+
+ // Create directories if they don't exist
+ $directory = dirname($destination);
+ if (!is_dir($directory)) {
+ mkdir($directory, 0755, true);
+ }
+
+ file_put_contents($destination, $content);
+
+ $results[$path] = true;
+ } catch (\Throwable $e) {
+ $results[$path] = $e->getMessage();
+ }
+
+ if ($onFile) {
+ $onFile($path, $results[$path]);
+ }
+ }
+
+ return $results;
+ }
+}
diff --git a/app/Services/Import/ProjectContentService.php b/app/Services/Import/ProjectContentService.php
new file mode 100644
index 0000000..a248a9e
--- /dev/null
+++ b/app/Services/Import/ProjectContentService.php
@@ -0,0 +1,376 @@
+ content -> type).
+ * - Resolves Project IDs dynamically by using the folder name as a slug via the DomainManagerService.
+ * - Parses YAML Front Matter reliably using Spatie\YamlFrontMatter.
+ * - Uses DTOs for data integrity with automatic snake_case mapping to match your database columns.
+ * - Handles Mass Assignment safely by ensuring the Content model's $fillable array is correctly configured.
+ * - Captures Metadata that doesn't fit into standard columns into a JSON blob.
+ *
+ * Directory structure supported:
+ * content/{contentType}/ → no category assigned
+ * content/{contentType}/{category-slug}/ → category assigned via directory name
+ */
+class ProjectContentService
+{
+ private string $basePath;
+ private array $errors = [];
+ private array $success = [];
+
+ public function getErrors(): array
+ {
+ return $this->errors;
+ }
+
+ public function getSuccess(): array
+ {
+ return $this->success;
+ }
+
+ public function __construct(protected DomainManagerService $domainManager)
+ {
+ $this->basePath = storage_path('app/imports/projects');
+ }
+
+ /**
+ * Main entry point to handle the import process.
+ */
+ public function handle(): void
+ {
+ try {
+ if (!File::isDirectory($this->basePath)) {
+ throw new Exception("Import directory not found: {$this->basePath}");
+ }
+
+ $projectDirs = File::directories($this->basePath);
+
+ foreach ($projectDirs as $projectPath) {
+ $projectName = basename($projectPath);
+ $this->processProject($projectName, $projectPath);
+ }
+
+ $this->logSummary();
+ } catch (Exception $e) {
+ Log::error("Import failed: " . $e->getMessage());
+ }
+ }
+
+ /**
+ * Process an individual project directory.
+ */
+ private function processProject(string $projectName, string $projectPath): void
+ {
+ $contentPath = $projectPath . DIRECTORY_SEPARATOR . 'content';
+
+ if (!File::isDirectory($contentPath)) {
+ Log::warning("Project [{$projectName}] has no 'content' subdirectory. Skipping.");
+ return;
+ }
+
+ // Get the project ID from config based on folder name
+ $this->domainManager->setSlug($projectName);
+ $projectId = $this->domainManager->getProjectId();
+
+ if (!$projectId) {
+ $this->errors[] = "Project configuration missing for: {$projectName}";
+ return;
+ }
+
+ $contentTypes = File::directories($contentPath);
+
+ foreach ($contentTypes as $typePath) {
+ $contentTypeStr = basename($typePath);
+ $this->processContentType($projectId, $contentTypeStr, $typePath, $projectName);
+ }
+ }
+
+ /**
+ * Process a contentType directory, supporting an optional category subdirectory level.
+ *
+ * Flat structure (no category):
+ * content/article/file.md → contentType=article, categorySlug=null
+ *
+ * Categorised structure:
+ * content/article/my-category/file.md → contentType=article, categorySlug='my-category'
+ *
+ * Both levels can coexist inside the same contentType directory.
+ */
+ private function processContentType(int $projectId, string $contentTypeStr, string $path, string $projectSlug): void
+ {
+ // --- Files directly inside the contentType directory (no category) ---
+ foreach (File::files($path) as $file) {
+ if ($file->getExtension() !== 'md') {
+ continue;
+ }
+
+ try {
+ $this->importFile($projectId, $contentTypeStr, $file->getPathname(), $projectSlug, null);
+ } catch (Exception $e) {
+ $this->errors[] = "File error [{$file->getFilename()}]: " . $e->getMessage();
+ }
+ }
+
+ // --- Files inside category subdirectories ---
+ foreach (File::directories($path) as $categoryPath) {
+ $categorySlug = basename($categoryPath);
+
+ foreach (File::files($categoryPath) as $file) {
+ if ($file->getExtension() !== 'md') {
+ continue;
+ }
+
+ try {
+ $this->importFile($projectId, $contentTypeStr, $file->getPathname(), $projectSlug, $categorySlug);
+ } catch (Exception $e) {
+ $this->errors[] = "File error [{$file->getFilename()}] (category: {$categorySlug}): " . $e->getMessage();
+ }
+ }
+ }
+ }
+
+ /**
+ * Parse the Markdown file and store it using the appropriate DTO.
+ *
+ * @param int $projectId
+ * @param string $contentTypeStr
+ * @param string $filePath
+ * @param string $projectSlug
+ * @param string|null $directoryCategorySlug Category slug derived from the parent directory name.
+ * When set, it takes precedence over the YAML 'categories' field.
+ */
+ private function importFile(
+ int $projectId,
+ string $contentTypeStr,
+ string $filePath,
+ string $projectSlug,
+ ?string $directoryCategorySlug
+ ): void {
+ $fileContent = file_get_contents($filePath);
+ if ($fileContent === false) {
+ throw new Exception("Could not read file.");
+ }
+
+ $object = YamlFrontMatter::parse($fileContent);
+
+ // Basic validation: ensure we have at least a title or slug
+ if (!$object->matter('title') && !$object->matter('slug')) {
+ throw new Exception("Missing required YAML front matter (title or slug).");
+ }
+
+ $content = $this->handleContentImport($projectId, $contentTypeStr, $object, $projectSlug);
+ if (!$content) {
+ return;
+ }
+
+ // Resolve the effective category: directory name wins over YAML front matter.
+ $effectiveCategorySlug = $directoryCategorySlug ?? $object->matter('categories');
+
+ $tagIds = $this->createTag($object->matter('tags') ?? '', $content);
+ if (count($tagIds) > 0) {
+ $content->tags()->sync($tagIds);
+ }
+
+ $categoryIds = $effectiveCategorySlug
+ ? $this->getCategories($effectiveCategorySlug, $content)
+ : [];
+
+ if (count($categoryIds) > 0) {
+ $content->categories()->sync($categoryIds);
+ }
+ }
+
+ /**
+ * Handle generic content (articles, posts, etc.)
+ */
+ private function handleContentImport(int $projectId, string $contentTypeStr, Document $object, string $projectSlug): ?Content
+ {
+ $content = null;
+ $slug = $object->matter('slug') ?? Str::slug($object->matter('title'));
+ try {
+ // Map YAML to ContentData DTO
+ $data = [
+ 'uuid' => $object->matter('uuid') ?? (string)Str::uuid(),
+ 'projectId' => $projectId,
+ 'userId' => $object->matter('user_id') ?? 1,
+ 'authorId' => $object->matter('author_id'),
+ 'parentId' => $this->getParentId($object->matter('parent_id'), $contentTypeStr),
+ 'contentType' => ContentContentType::tryFrom($contentTypeStr) ?? ContentContentType::Article->value,
+ 'status' => ContentStatus::tryFrom($object->matter('status') ?? '')
+ ?? ContentStatus::Draft,
+
+ 'visibility' => ContentVisibility::tryFrom($object->matter('visibility') ?? '')
+ ?? ContentVisibility::Public,
+
+ 'lang' => Language::tryFrom($object->matter('lang') ?? '')
+ ?? Language::EN,
+ 'slug' => $slug,
+ 'title' => (string)$object->matter('title'),
+ 'subtitle' => $object->matter('subtitle'),
+ 'excerpt' => $object->matter('description') ?? $object->matter('excerpt'),
+ 'content' => $object->body(),
+ 'metadata' => $this->extractMetadata($object, ['title', 'slug', 'description', 'is_published']),
+ 'position' => (int)($object->matter('position') ?? 0),
+ 'isFeatured' => (bool)($object->matter('is_featured') ?? false),
+ 'publishedAt' => $object->matter('published_at') ? Carbon::parse($object->matter('published_at')) : null,
+ ];
+ $data['metadata'] = $this->extractMetadata($object, array_keys($data), ['categories', 'tags', 'parent', 'is_featured']);
+ $data['metadata']['coverImage'] = $this->getContentImageUrl($projectSlug, $contentTypeStr, ($object->matter('imageDirectory') ?? '') . '/' . config('myapp.image.cover'));
+ $data['metadata']['featuredImage'] = $this->getContentImageUrl($projectSlug, $contentTypeStr, ($object->matter('imageDirectory') ?? '') . '/' . config('myapp.image.featured'));
+ if ($contentTypeStr === ContentContentType::Place->value) {
+ $data['metadata']['coverImage'] = $this->getPlaceImageUrl($projectSlug, $slug, config('myapp.image.cover'));
+ $data['metadata']['featuredImage'] = $this->getPlaceImageUrl($projectSlug, $slug, config('myapp.image.featured'));
+ }
+
+ $dto = ContentData::from($data);
+ $content = Content::updateOrCreate(
+ ['slug' => $dto->slug, 'project_id' => $dto->projectId],
+ $dto->toArray()
+ );
+ $this->success[] = "Imported Content: {$dto->title}";
+ } catch (Exception $e) {
+ $this->errors[] = "Content Save Error [{$object->matter('title')}]: " . $e->getMessage();
+ }
+ return $content;
+ }
+
+ private function getContentImageUrl(string $projectSlug, string $contentType, string $filename): ?string
+ {
+ $imagePath = storage_path('app/public/' . $projectSlug . '/images/' . $contentType . '/' . $filename);
+ if (File::exists($imagePath)) {
+ return 'storage/' . $projectSlug . '/images/' . $contentType . '/' . $filename;
+ }
+ return null;
+ }
+
+ private function getPlaceImageUrl(string $albumName, string $eventName, string $filename): ?string
+ {
+ $imagePath = storage_path('app/public/albums/' . $albumName . '/' . $eventName . '/' . $filename);
+ if (File::exists($imagePath)) {
+ return 'storage/albums/' . $albumName . '/' . $eventName . '/' . $filename;
+ }
+ return null;
+ }
+
+ private function getParentId(string|null $parentSlug, string $contentType): ?int
+ {
+ if (empty($parentSlug)) {
+ return null;
+ }
+
+ return Content::where('slug', $parentSlug)
+ ->publishedByType($contentType)
+ ->value('id');
+ }
+
+ private function getCategories(string $categories, Content $content): array
+ {
+ $categorySlugs = array_map('trim', explode(',', $categories));
+ $categoryIds = [];
+ foreach ($categorySlugs as $slug) {
+ try {
+ $category = Category::where('slug', $slug)->publishedByType($content->content_type)->first();
+ if (!$category) {
+ continue;
+ }
+ $this->success[] = 'Found category slug: ' . $slug . ' | content slug "' . $content->slug;
+ $categoryIds[] = $category->id;
+ } catch (Exception $e) {
+ $this->errors[] = "Category Save Error: " . $e->getMessage();
+ continue;
+ }
+ }
+ return $categoryIds;
+ }
+
+ private function createTag(string $tags, Content $content): array
+ {
+ $tagNames = array_map('trim', explode(',', $tags));
+ $tagIds = [];
+ foreach ($tagNames as $tagName) {
+ if ($tagName === '') {
+ continue;
+ }
+ try {
+ $dto = TagData::from([
+ 'projectId' => $content->project_id,
+ 'contentType' => $content->content_type,
+ 'lang' => $content->lang->value,
+ 'name' => $tagName,
+ ]);
+
+ $tag = Tag::updateOrCreate(
+ ['name' => $dto->name, 'project_id' => $dto->projectId],
+ $dto->toArray()
+ );
+
+ if ($tag) {
+ $tagIds[] = $tag->id;
+ }
+
+ $this->success[] = "Imported Tag: {$dto->name}";
+ } catch (Exception $e) {
+ $this->errors[] = "Tag Save Error: " . $e->getMessage();
+ continue;
+ }
+ }
+ return $tagIds;
+ }
+
+ /**
+ * Extracts metadata from YAML front matter.
+ *
+ * @param Document $object The parsed markdown document
+ * @param array $excludeKeys Keys already mapped to DTO/Database columns (e.g., 'title', 'slug')
+ * @param array $ignoreKeys Keys that should be completely discarded (e.g., 'internal_note', 'temp_id')
+ * @return array
+ */
+ private function extractMetadata(Document $object, array $excludeKeys, array $ignoreKeys = []): array
+ {
+ return array_filter($object->matter(), function ($key) use ($excludeKeys, $ignoreKeys) {
+ return !in_array($key, $excludeKeys) && !in_array($key, $ignoreKeys);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+ /**
+ * Log results of the import.
+ */
+ private function logSummary(): void
+ {
+ foreach ($this->success as $msg) {
+ Log::info($msg);
+ }
+
+ if (!empty($this->errors)) {
+ Log::error("Import completed with errors:");
+ foreach ($this->errors as $error) {
+ Log::error($error);
+ }
+ }
+ }
+}
diff --git a/app/Traits/FilterByProject.php b/app/Traits/FilterByProject.php
new file mode 100644
index 0000000..4d7d269
--- /dev/null
+++ b/app/Traits/FilterByProject.php
@@ -0,0 +1,37 @@
+getProjectId();
+
+ if ($projectId) {
+ $builder->where('project_id', $projectId);
+ } else {
+ // Safety: If no project identified, return no results
+ $builder->whereRaw('1 = 0');
+ }
+ });
+
+ // 2. Auto-assign ID on Create
+ static::creating(function (Model $model) {
+ $manager = app(DomainManagerService::class);
+ if (!$model->project_id && $manager->getProjectId()) {
+ $model->project_id = $manager->getProjectId();
+ }
+ });
+ }
+}
diff --git a/app/Traits/HasDynamicContent.php b/app/Traits/HasDynamicContent.php
new file mode 100644
index 0000000..2087599
--- /dev/null
+++ b/app/Traits/HasDynamicContent.php
@@ -0,0 +1,38 @@
+{$column};
+
+ if (empty($raw)) {
+ return '';
+ }
+
+ // 1. Render the Blade tags first
+ $renderedBlade = Blade::render($raw, $data);
+
+ /**
+ * 2. Fix: Remove leading indentation.
+ * We use regex to remove spaces/tabs from the start of every line.
+ * This prevents Markdown from turning indented HTML into
+
+
+@endif
\ No newline at end of file
diff --git a/resources/views/components/_shared/dropdown.blade.php b/resources/views/components/_shared/dropdown.blade.php
new file mode 100644
index 0000000..82f9227
--- /dev/null
+++ b/resources/views/components/_shared/dropdown.blade.php
@@ -0,0 +1,22 @@
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/gtag.blade.php b/resources/views/components/_shared/gtag.blade.php
new file mode 100644
index 0000000..87b6976
--- /dev/null
+++ b/resources/views/components/_shared/gtag.blade.php
@@ -0,0 +1,10 @@
+@if(config('myapp.gatMeasurementId'))
+
+
+@endif
\ No newline at end of file
diff --git a/resources/views/components/_shared/iframe.blade.php b/resources/views/components/_shared/iframe.blade.php
new file mode 100644
index 0000000..e281fcb
--- /dev/null
+++ b/resources/views/components/_shared/iframe.blade.php
@@ -0,0 +1,9 @@
+@props(['src'])
+
class(['iframe'])}}>
+
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/img-svg.blade.php b/resources/views/components/_shared/img-svg.blade.php
new file mode 100644
index 0000000..1ef5a57
--- /dev/null
+++ b/resources/views/components/_shared/img-svg.blade.php
@@ -0,0 +1,10 @@
+@php
+
+ $img = $img ?? '';
+ $fullPath = storage_path('app/public/images/svg/' . $img . '.svg');
+@endphp
+
+@if(file_exists($fullPath))
+ {!! file_get_contents($fullPath) !!}
+ @endif
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/jumbotron.blade.php b/resources/views/components/_shared/jumbotron.blade.php
new file mode 100644
index 0000000..eff1679
--- /dev/null
+++ b/resources/views/components/_shared/jumbotron.blade.php
@@ -0,0 +1,6 @@
+
+ class(['jumbotron'])}}>
+
+ {{$slot}}
+
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/lightbox.blade.php b/resources/views/components/_shared/lightbox.blade.php
new file mode 100644
index 0000000..015bc8f
--- /dev/null
+++ b/resources/views/components/_shared/lightbox.blade.php
@@ -0,0 +1,99 @@
+@props(['images'])
+@props(['title'])
+@isset($images)
+
+
+
+
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/pagination.blade.php b/resources/views/components/_shared/pagination.blade.php
new file mode 100644
index 0000000..afade91
--- /dev/null
+++ b/resources/views/components/_shared/pagination.blade.php
@@ -0,0 +1,35 @@
+
diff --git a/resources/views/components/_shared/panel.blade.php b/resources/views/components/_shared/panel.blade.php
new file mode 100644
index 0000000..c311921
--- /dev/null
+++ b/resources/views/components/_shared/panel.blade.php
@@ -0,0 +1,9 @@
+
class(['panel'])}}>
+ @isset($header)
+
attributes->class(['panel-heade'])}}>{{$header}}
+ @endisset
+
+ @isset($body)
+
attributes->class(['panel-body'])}}>{{$body}}
+ @endisset
+
\ No newline at end of file
diff --git a/resources/views/components/_shared/post-image.blade.php b/resources/views/components/_shared/post-image.blade.php
new file mode 100644
index 0000000..f848c69
--- /dev/null
+++ b/resources/views/components/_shared/post-image.blade.php
@@ -0,0 +1,18 @@
+@props(['postImage'])
+class([])}}>
+ @if(!empty($postImage->title))
+
\ No newline at end of file
diff --git a/resources/views/components/default/partials/footer/index.blade.php b/resources/views/components/default/partials/footer/index.blade.php
new file mode 100644
index 0000000..3acd476
--- /dev/null
+++ b/resources/views/components/default/partials/footer/index.blade.php
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/resources/views/components/default/partials/footer/nav.blade.php b/resources/views/components/default/partials/footer/nav.blade.php
new file mode 100644
index 0000000..4dbfb65
--- /dev/null
+++ b/resources/views/components/default/partials/footer/nav.blade.php
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/resources/views/components/default/partials/header/brand.blade.php b/resources/views/components/default/partials/header/brand.blade.php
new file mode 100644
index 0000000..02e0f18
--- /dev/null
+++ b/resources/views/components/default/partials/header/brand.blade.php
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/resources/views/components/martinvach/home/features.blade.php b/resources/views/components/martinvach/home/features.blade.php
new file mode 100644
index 0000000..2bdfab7
--- /dev/null
+++ b/resources/views/components/martinvach/home/features.blade.php
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
This is feature 1
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ HashTag
+ HashTag
+ HashTag
+
+
+
+
+
+
+
+
+
+
This is feature 2
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ HashTag
+ HashTag
+ HashTag
+
+
+
+
+
+
+
+
+
+
This is feature 3
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ HashTag
+ HashTag
+ HashTag
+
+
+
+
+
+
+
+
+
+
This is feature 4
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ HashTag
+ HashTag
+ HashTag
+
+
+
+
+
+
+
+
+
+
This is feature 5
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ HashTag
+ HashTag
+ HashTag
+
+
+
+
+
+
+
+
+
+
This is feature 6
+
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
\ No newline at end of file
diff --git a/resources/views/components/martinvach/home/places-featured.blade.php b/resources/views/components/martinvach/home/places-featured.blade.php
new file mode 100644
index 0000000..0fd6a3b
--- /dev/null
+++ b/resources/views/components/martinvach/home/places-featured.blade.php
@@ -0,0 +1,23 @@
+
+
\ No newline at end of file
diff --git a/resources/views/components/martinvach/home/places.blade.php b/resources/views/components/martinvach/home/places.blade.php
new file mode 100644
index 0000000..93c9a5a
--- /dev/null
+++ b/resources/views/components/martinvach/home/places.blade.php
@@ -0,0 +1,29 @@
+
+
\ No newline at end of file
diff --git a/resources/views/components/ui/my-gtag/index.blade.php b/resources/views/components/ui/my-gtag/index.blade.php
new file mode 100644
index 0000000..87b6976
--- /dev/null
+++ b/resources/views/components/ui/my-gtag/index.blade.php
@@ -0,0 +1,10 @@
+@if(config('myapp.gatMeasurementId'))
+
+
+@endif
\ No newline at end of file
diff --git a/resources/views/components/ui/my-iframe/index.blade.php b/resources/views/components/ui/my-iframe/index.blade.php
new file mode 100644
index 0000000..14bcb78
--- /dev/null
+++ b/resources/views/components/ui/my-iframe/index.blade.php
@@ -0,0 +1,10 @@
+@props(['src'])
+
+
+That's fine. Drop a few lines about your project or challenge and I will take it from there.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/resources/views/components/vades/home/features.blade.php b/resources/views/components/vades/home/features.blade.php
new file mode 100644
index 0000000..81187ca
--- /dev/null
+++ b/resources/views/components/vades/home/features.blade.php
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
Architecture Built to Last
+
Systems designed around your actual growth — not yesterday's assumptions or tomorrow's rewrites.
+
+ {{-- The dropdown with the search results.
+ It is only shown if the query is not empty and there are search results. --}}
+ @if(!empty($query) && !empty($results))
+
+
+
+
+
+{{-- The dropdown with the search results.
+ It is only shown if the query is not empty and there are search results. --}}
+@if(!empty($query) && !empty($results))
+
+