Compare commits
10 Commits
05777cff8e
...
084c52ba2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
084c52ba2d | ||
| e37f533bcb | |||
|
|
30ca8082b3 | ||
| 6f6ea8b24f | |||
| bc353fa2c2 | |||
| 8e8d934b4b | |||
| 77e56b8758 | |||
| 1975e26f94 | |||
| 6de9881806 | |||
| ecf9ea061b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,8 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/PhotoboothUploader/bin
|
||||||
|
/PhotoboothUploader/obj
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/vendor
|
/vendor
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"mcpServers":{"laravel-boost":{"command":"php","args":["artisan","boost:mcp"],"transport":"stdio","alwaysAllow":["application-info","search-docs"],"disabled":false}}}
|
|
||||||
File diff suppressed because one or more lines are too long
281
AGENTS.md
281
AGENTS.md
@@ -8,8 +8,8 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
|
|||||||
## Foundational Context
|
## 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.
|
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.3.24
|
- php - 8.3.29
|
||||||
- filament/filament (FILAMENT) - v3
|
- filament/filament (FILAMENT) - v4
|
||||||
- inertiajs/inertia-laravel (INERTIA) - v1
|
- inertiajs/inertia-laravel (INERTIA) - v1
|
||||||
- laravel/breeze (BREEZE) - v2
|
- laravel/breeze (BREEZE) - v2
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
@@ -23,11 +23,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- phpunit/phpunit (PHPUNIT) - v12
|
- phpunit/phpunit (PHPUNIT) - v12
|
||||||
- laravel-echo (ECHO) - v2
|
- laravel-echo (ECHO) - v2
|
||||||
- @inertiajs/vue3 (INERTIA) - v1
|
- @inertiajs/vue3 (INERTIA) - v1
|
||||||
- tailwindcss (TAILWINDCSS) - v3
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
- vue (VUE) - v3
|
- vue (VUE) - v3
|
||||||
|
|
||||||
## Conventions
|
## 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()`.
|
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
|
||||||
- Check for existing components to reuse before writing a new one.
|
- Check for existing components to reuse before writing a new one.
|
||||||
|
|
||||||
@@ -35,7 +35,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.
|
- 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
|
## 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.
|
- Do not change the application's dependencies without approval.
|
||||||
|
|
||||||
## Frontend Bundling
|
## Frontend Bundling
|
||||||
@@ -47,17 +47,16 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
## Documentation Files
|
## Documentation Files
|
||||||
- You must only create documentation files if explicitly requested by the user.
|
- You must only create documentation files if explicitly requested by the user.
|
||||||
|
|
||||||
|
|
||||||
=== boost rules ===
|
=== boost rules ===
|
||||||
|
|
||||||
## Laravel Boost
|
## Laravel Boost
|
||||||
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
|
||||||
|
|
||||||
## Artisan
|
## 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
|
## 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
|
## Tinker / Debugging
|
||||||
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
|
||||||
@@ -68,22 +67,21 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- Only recent browser logs will be useful - ignore old logs.
|
- Only recent browser logs will be useful - ignore old logs.
|
||||||
|
|
||||||
## Searching Documentation (Critically Important)
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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']`.
|
- 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`.
|
- 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
|
### Available Search Syntax
|
||||||
- You can and should pass multiple queries at once. The most relevant results will be returned first.
|
- 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'
|
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"
|
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
|
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"
|
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
|
||||||
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
|
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
|
||||||
|
|
||||||
|
|
||||||
=== php rules ===
|
=== php rules ===
|
||||||
|
|
||||||
@@ -94,7 +92,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
### Constructors
|
### Constructors
|
||||||
- Use PHP 8 constructor property promotion in `__construct()`.
|
- Use PHP 8 constructor property promotion in `__construct()`.
|
||||||
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
|
||||||
- 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
|
### Type Declarations
|
||||||
- Always use explicit return type declarations for methods and functions.
|
- Always use explicit return type declarations for methods and functions.
|
||||||
@@ -108,7 +106,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
## Comments
|
## 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
|
## PHPDoc Blocks
|
||||||
- Add useful array shape type definitions for arrays when appropriate.
|
- Add useful array shape type definitions for arrays when appropriate.
|
||||||
@@ -116,16 +114,29 @@ protected function isAccessible(User $user, ?string $path = null): bool
|
|||||||
## Enums
|
## Enums
|
||||||
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
|
- 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 --compact` with a specific filename or filter.
|
||||||
|
|
||||||
=== inertia-laravel/core rules ===
|
=== inertia-laravel/core rules ===
|
||||||
|
|
||||||
## Inertia Core
|
## Inertia
|
||||||
|
|
||||||
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (vite.config.js).
|
- Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
|
||||||
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
- Use `Inertia::render()` for server-side routing instead of traditional Blade views.
|
||||||
- Use `search-docs` for accurate guidance on all things Inertia.
|
- Use the `search-docs` tool for accurate guidance on all things Inertia.
|
||||||
|
|
||||||
<code-snippet lang="php" name="Inertia::render Example">
|
<code-snippet name="Inertia Render Example" lang="php">
|
||||||
// routes/web.php example
|
// routes/web.php example
|
||||||
Route::get('/users', function () {
|
Route::get('/users', function () {
|
||||||
return Inertia::render('Users/Index', [
|
return Inertia::render('Users/Index', [
|
||||||
@@ -134,30 +145,28 @@ Route::get('/users', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-laravel/v1 rules ===
|
=== inertia-laravel/v1 rules ===
|
||||||
|
|
||||||
## Inertia v1
|
## Inertia v1
|
||||||
|
|
||||||
- Inertia v1 does _not_ come with these features. Do not recommend using these Inertia v2 features directly.
|
- Inertia v1 does not come with these features. Do not recommend using these Inertia v2 features directly:
|
||||||
- Polling
|
- Deferred props.
|
||||||
- Prefetching
|
- Infinite scrolling using merging props and `WhenVisible`.
|
||||||
- Deferred props
|
- Lazy loading data on scroll.
|
||||||
- Infinite scrolling using merging props and `WhenVisible`
|
- Polling.
|
||||||
- Lazy loading data on scroll
|
- Prefetching.
|
||||||
|
|
||||||
|
|
||||||
=== laravel/core rules ===
|
=== laravel/core rules ===
|
||||||
|
|
||||||
## Do Things the Laravel Way
|
## 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.
|
- 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 `artisan make:class`.
|
- 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.
|
- 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
|
### Database
|
||||||
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
|
- 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
|
- 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.
|
- 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.
|
- Generate code that prevents N+1 query problems by using eager loading.
|
||||||
- Use Laravel's query builder for very complex database operations.
|
- Use Laravel's query builder for very complex database operations.
|
||||||
@@ -187,41 +196,41 @@ Route::get('/users', function () {
|
|||||||
### Testing
|
### 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.
|
- 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()`.
|
- 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.
|
- 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
|
### 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`.
|
- 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/v12 rules ===
|
||||||
|
|
||||||
## Laravel 12
|
## Laravel 12
|
||||||
|
|
||||||
- Use the `search-docs` tool to get version specific documentation.
|
- Use the `search-docs` tool to get version-specific documentation.
|
||||||
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
|
||||||
|
|
||||||
### Laravel 12 Structure
|
### Laravel 12 Structure
|
||||||
- No middleware files in `app/Http/Middleware/`.
|
- 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/app.php` is the file to register middleware, exceptions, and routing files.
|
||||||
- `bootstrap/providers.php` contains application specific service providers.
|
- `bootstrap/providers.php` contains application specific service providers.
|
||||||
- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
|
- The `app\Console\Kernel.php` file no longer exists; 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.
|
- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
|
||||||
|
|
||||||
### Database
|
### 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.
|
- 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);`.
|
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
|
||||||
|
|
||||||
### Models
|
### 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.
|
- 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/core rules ===
|
||||||
|
|
||||||
## Livewire Core
|
## Livewire
|
||||||
- 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
|
- 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.
|
- 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.
|
- 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 Best Practices
|
||||||
- Livewire components require a single root element.
|
- Livewire components require a single root element.
|
||||||
@@ -238,15 +247,14 @@ Route::get('/users', function () {
|
|||||||
|
|
||||||
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
|
||||||
|
|
||||||
<code-snippet name="Lifecycle hook examples" lang="php">
|
<code-snippet name="Lifecycle Hook Examples" lang="php">
|
||||||
public function mount(User $user) { $this->user = $user; }
|
public function mount(User $user) { $this->user = $user; }
|
||||||
public function updatedSearch() { $this->resetPage(); }
|
public function updatedSearch() { $this->resetPage(); }
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
## Testing Livewire
|
## Testing Livewire
|
||||||
|
|
||||||
<code-snippet name="Example Livewire component test" lang="php">
|
<code-snippet name="Example Livewire Component Test" lang="php">
|
||||||
Livewire::test(Counter::class)
|
Livewire::test(Counter::class)
|
||||||
->assertSet('count', 0)
|
->assertSet('count', 0)
|
||||||
->call('increment')
|
->call('increment')
|
||||||
@@ -255,19 +263,17 @@ Route::get('/users', function () {
|
|||||||
->assertStatus(200);
|
->assertStatus(200);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
|
||||||
<code-snippet name="Testing a Livewire component exists within a page" lang="php">
|
|
||||||
$this->get('/posts/create')
|
$this->get('/posts/create')
|
||||||
->assertSeeLivewire(CreatePost::class);
|
->assertSeeLivewire(CreatePost::class);
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== livewire/v3 rules ===
|
=== livewire/v3 rules ===
|
||||||
|
|
||||||
## Livewire 3
|
## Livewire 3
|
||||||
|
|
||||||
### Key Changes From Livewire 2
|
### 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.
|
- These things changed in Livewire 3, 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.
|
- 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`).
|
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
|
||||||
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
|
||||||
@@ -277,13 +283,13 @@ Route::get('/users', function () {
|
|||||||
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
|
||||||
|
|
||||||
### Alpine
|
### Alpine
|
||||||
- Alpine is now included with Livewire, don't manually include Alpine.js.
|
- Alpine is now included with Livewire; don't manually include Alpine.js.
|
||||||
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
- Plugins included with Alpine: persist, intersect, collapse, and focus.
|
||||||
|
|
||||||
### Lifecycle Hooks
|
### Lifecycle Hooks
|
||||||
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
|
||||||
|
|
||||||
<code-snippet name="livewire:load example" lang="js">
|
<code-snippet name="Livewire Init Hook Example" lang="js">
|
||||||
document.addEventListener('livewire:init', function () {
|
document.addEventListener('livewire:init', function () {
|
||||||
Livewire.hook('request', ({ fail }) => {
|
Livewire.hook('request', ({ fail }) => {
|
||||||
if (fail && fail.status === 419) {
|
if (fail && fail.status === 419) {
|
||||||
@@ -297,7 +303,6 @@ document.addEventListener('livewire:init', function () {
|
|||||||
});
|
});
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== pint/core rules ===
|
=== pint/core rules ===
|
||||||
|
|
||||||
## Laravel Pint Code Formatter
|
## Laravel Pint Code Formatter
|
||||||
@@ -305,24 +310,22 @@ document.addEventListener('livewire:init', function () {
|
|||||||
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
|
- 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.
|
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
|
||||||
|
|
||||||
|
|
||||||
=== phpunit/core rules ===
|
=== phpunit/core rules ===
|
||||||
|
|
||||||
## PHPUnit Core
|
## PHPUnit
|
||||||
|
|
||||||
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit <name>` to create a new test.
|
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
|
||||||
- If you see a test using "Pest", convert it to PHPUnit.
|
- If you see a test using "Pest", convert it to PHPUnit.
|
||||||
- Every time a test has been updated, run that singular test.
|
- Every time a test has been updated, run that singular test.
|
||||||
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
|
||||||
- Tests should test all of the happy paths, failure paths, and weird paths.
|
- Tests should test all of the happy paths, failure paths, and weird paths.
|
||||||
- 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.
|
- 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.
|
||||||
|
|
||||||
### Running Tests
|
### Running Tests
|
||||||
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
- Run the minimal number of tests, using an appropriate filter, before finalizing.
|
||||||
- To run all tests: `php artisan test`.
|
- To run all tests: `php artisan test --compact`.
|
||||||
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
|
- 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 --filter=testName` (recommended after making a change to a related file).
|
- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
|
||||||
|
|
||||||
|
|
||||||
=== inertia-vue/core rules ===
|
=== inertia-vue/core rules ===
|
||||||
|
|
||||||
@@ -338,15 +341,13 @@ document.addEventListener('livewire:init', function () {
|
|||||||
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== inertia-vue/v1/forms rules ===
|
=== inertia-vue/v1/forms rules ===
|
||||||
|
|
||||||
## Inertia + Vue Forms
|
## Inertia v1 + Vue Forms
|
||||||
|
|
||||||
- For form handling in Inertia pages, use `router.post` and related methods. Do not use regular forms.
|
- For form handling in Inertia pages, use `router.post` and related methods. Do not use regular forms.
|
||||||
|
|
||||||
|
<code-snippet name="Inertia Vue Form Example" lang="vue">
|
||||||
<code-snippet lang="vue" name="Inertia Vue Form Example">
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { reactive } from 'vue'
|
import { reactive } from 'vue'
|
||||||
import { router } from '@inertiajs/vue3'
|
import { router } from '@inertiajs/vue3'
|
||||||
@@ -379,18 +380,17 @@ document.addEventListener('livewire:init', function () {
|
|||||||
</template>
|
</template>
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
=== tailwindcss/core rules ===
|
=== 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.
|
- 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..)
|
- 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
|
- 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.
|
- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
|
||||||
|
|
||||||
### Spacing
|
### Spacing
|
||||||
- When listing items, use gap utilities for spacing, don't use margins.
|
- When listing items, use gap utilities for spacing; don't use margins.
|
||||||
|
|
||||||
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
@@ -400,116 +400,47 @@ document.addEventListener('livewire:init', function () {
|
|||||||
</div>
|
</div>
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
|
||||||
### Dark Mode
|
### 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:`.
|
- 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 ===
|
||||||
|
|
||||||
=== tailwindcss/v3 rules ===
|
## Tailwind CSS 4
|
||||||
|
|
||||||
## Tailwind 3
|
- 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.
|
||||||
|
|
||||||
- Always use Tailwind CSS v3 - verify you're using only classes supported by this version.
|
<code-snippet name="Extending Theme in CSS" lang="css">
|
||||||
|
@theme {
|
||||||
|
--color-brand: oklch(0.72 0.11 178);
|
||||||
=== 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.
|
|
||||||
|
|
||||||
|
|
||||||
=== filament/filament rules ===
|
|
||||||
|
|
||||||
## Filament
|
|
||||||
- Filament is used by this application, check how and where to follow existing application conventions.
|
|
||||||
- Filament is a Server-Driven UI (SDUI) framework for Laravel. It allows developers to define user interfaces in PHP using structured configuration objects. It is built on top of Livewire, Alpine.js, and Tailwind CSS.
|
|
||||||
- You can use the `search-docs` tool to get information from the official Filament documentation when needed. This is very useful for Artisan command arguments, specific code examples, testing functionality, relationship management, and ensuring you're following idiomatic practices.
|
|
||||||
- Utilize static `make()` methods for consistent component initialization.
|
|
||||||
|
|
||||||
### Artisan
|
|
||||||
- You must use the Filament specific Artisan commands to create new files or components for Filament. You can find these with the `list-artisan-commands` tool, or with `php artisan` and the `--help` option.
|
|
||||||
- Inspect the required options, always pass `--no-interaction`, and valid arguments for other options when applicable.
|
|
||||||
|
|
||||||
### Filament's Core Features
|
|
||||||
- Actions: Handle doing something within the application, often with a button or link. Actions encapsulate the UI, the interactive modal window, and the logic that should be executed when the modal window is submitted. They can be used anywhere in the UI and are commonly used to perform one-time actions like deleting a record, sending an email, or updating data in the database based on modal form input.
|
|
||||||
- Forms: Dynamic forms rendered within other features, such as resources, action modals, table filters, and more.
|
|
||||||
- Infolists: Read-only lists of data.
|
|
||||||
- Notifications: Flash notifications displayed to users within the application.
|
|
||||||
- Panels: The top-level container in Filament that can include all other features like pages, resources, forms, tables, notifications, actions, infolists, and widgets.
|
|
||||||
- Resources: Static classes that are used to build CRUD interfaces for Eloquent models. Typically live in `app/Filament/Resources`.
|
|
||||||
- Schemas: Represent components that define the structure and behavior of the UI, such as forms, tables, or lists.
|
|
||||||
- Tables: Interactive tables with filtering, sorting, pagination, and more.
|
|
||||||
- Widgets: Small component included within dashboards, often used for displaying data in charts, tables, or as a stat.
|
|
||||||
|
|
||||||
### Relationships
|
|
||||||
- Determine if you can use the `relationship()` method on form components when you need `options` for a select, checkbox, repeater, or when building a `Fieldset`:
|
|
||||||
|
|
||||||
<code-snippet name="Relationship example for Form Select" lang="php">
|
|
||||||
Forms\Components\Select::make('user_id')
|
|
||||||
->label('Author')
|
|
||||||
->relationship('author')
|
|
||||||
->required(),
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
|
- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
|
||||||
|
|
||||||
## Testing
|
<code-snippet name="Tailwind v4 Import Tailwind Diff" lang="diff">
|
||||||
- It's important to test Filament functionality for user satisfaction.
|
- @tailwind base;
|
||||||
- Ensure that you are authenticated to access the application within the test.
|
- @tailwind components;
|
||||||
- Filament uses Livewire, so start assertions with `livewire()` or `Livewire::test()`.
|
- @tailwind utilities;
|
||||||
|
+ @import "tailwindcss";
|
||||||
### Example Tests
|
|
||||||
|
|
||||||
<code-snippet name="Filament Table Test" lang="php">
|
|
||||||
livewire(ListUsers::class)
|
|
||||||
->assertCanSeeTableRecords($users)
|
|
||||||
->searchTable($users->first()->name)
|
|
||||||
->assertCanSeeTableRecords($users->take(1))
|
|
||||||
->assertCanNotSeeTableRecords($users->skip(1))
|
|
||||||
->searchTable($users->last()->email)
|
|
||||||
->assertCanSeeTableRecords($users->take(-1))
|
|
||||||
->assertCanNotSeeTableRecords($users->take($users->count() - 1));
|
|
||||||
</code-snippet>
|
</code-snippet>
|
||||||
|
|
||||||
<code-snippet name="Filament Create Resource Test" lang="php">
|
### Replaced Utilities
|
||||||
livewire(CreateUser::class)
|
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
|
||||||
->fillForm([
|
- Opacity values are still numeric.
|
||||||
'name' => 'Howdy',
|
|
||||||
'email' => 'howdy@example.com',
|
|
||||||
])
|
|
||||||
->call('create')
|
|
||||||
->assertNotified()
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
assertDatabaseHas(User::class, [
|
| Deprecated | Replacement |
|
||||||
'name' => 'Howdy',
|
|------------+--------------|
|
||||||
'email' => 'howdy@example.com',
|
| bg-opacity-* | bg-black/* |
|
||||||
]);
|
| text-opacity-* | text-black/* |
|
||||||
</code-snippet>
|
| border-opacity-* | border-black/* |
|
||||||
|
| divide-opacity-* | divide-black/* |
|
||||||
<code-snippet name="Testing Multiple Panels (setup())" lang="php">
|
| ring-opacity-* | ring-black/* |
|
||||||
use Filament\Facades\Filament;
|
| placeholder-opacity-* | placeholder-black/* |
|
||||||
|
| flex-shrink-* | shrink-* |
|
||||||
Filament::setCurrentPanel('app');
|
| flex-grow-* | grow-* |
|
||||||
</code-snippet>
|
| overflow-ellipsis | text-ellipsis |
|
||||||
|
| decoration-slice | box-decoration-slice |
|
||||||
<code-snippet name="Calling an Action in a Test" lang="php">
|
| decoration-clone | box-decoration-clone |
|
||||||
livewire(EditInvoice::class, [
|
|
||||||
'invoice' => $invoice,
|
|
||||||
])->callAction('send');
|
|
||||||
|
|
||||||
expect($invoice->refresh())->isSent()->toBeTrue();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
|
|
||||||
## Version 3 Changes To Focus On
|
|
||||||
- Resources are located in `app/Filament/Resources/` directory.
|
|
||||||
- Resource pages (List, Create, Edit) are auto-generated within the resource's directory - e.g., `app/Filament/Resources/PostResource/Pages/`.
|
|
||||||
- Forms use the `Forms\Components` namespace for form fields.
|
|
||||||
- Tables use the `Tables\Columns` namespace for table columns.
|
|
||||||
- A new `Filament\Forms\Components\RichEditor` component is available.
|
|
||||||
- Form and table schemas now use fluent method chaining.
|
|
||||||
- Added `php artisan filament:optimize` command for production optimization.
|
|
||||||
- Requires implementing `FilamentUser` contract for production access control.
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ COPY --from=frontend_build /var/www/html/public/build /var/www/html/public/build
|
|||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
mkdir -p storage/app/public storage/logs bootstrap/cache; \
|
mkdir -p storage/app/public storage/logs bootstrap/cache; \
|
||||||
chown -R www-data:www-data storage bootstrap/cache
|
chown -R www-data:www-data storage bootstrap/cache; \
|
||||||
|
ln -snf /var/www/html/storage/app/public /var/www/html/public/storage
|
||||||
|
|
||||||
FROM php_build AS php_app
|
FROM php_build AS php_app
|
||||||
|
|
||||||
|
|||||||
90
PhotoboothUploader/App.axaml
Normal file
90
PhotoboothUploader/App.axaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="PhotoboothUploader.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||||
|
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
<Style>
|
||||||
|
<Style.Resources>
|
||||||
|
<Color x:Key="BrandRose">#FFB6C1</Color>
|
||||||
|
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
|
||||||
|
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
|
||||||
|
<Color x:Key="BrandGold">#FFD700</Color>
|
||||||
|
<Color x:Key="BrandSky">#87CEEB</Color>
|
||||||
|
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
|
||||||
|
<Color x:Key="BrandNavy">#0F4C75</Color>
|
||||||
|
<Color x:Key="BrandSlate">#1F2937</Color>
|
||||||
|
<Color x:Key="BrandCream">#FFF8F5</Color>
|
||||||
|
|
||||||
|
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
|
||||||
|
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
|
||||||
|
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||||
|
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
|
||||||
|
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
|
||||||
|
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
|
||||||
|
|
||||||
|
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
|
||||||
|
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
|
||||||
|
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
|
||||||
|
</LinearGradientBrush>
|
||||||
|
</Style.Resources>
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Window">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
|
||||||
|
<Setter Property="FontFamily" Value="Inter" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.title">
|
||||||
|
<Setter Property="FontSize" Value="20" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBlock.subtitle">
|
||||||
|
<Setter Property="FontSize" Value="12" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="CornerRadius" Value="12" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Border.card.accent">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="TextBox">
|
||||||
|
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
|
||||||
|
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="10,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button">
|
||||||
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
|
<Setter Property="Padding" Value="12,8" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.primary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
|
<Style Selector="Button.secondary">
|
||||||
|
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
|
||||||
|
</Style>
|
||||||
|
</Application.Styles>
|
||||||
|
</Application>
|
||||||
23
PhotoboothUploader/App.axaml.cs
Normal file
23
PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader;
|
||||||
|
|
||||||
|
public partial class App : Application
|
||||||
|
{
|
||||||
|
public override void Initialize()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void OnFrameworkInitializationCompleted()
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
desktop.MainWindow = new MainWindow();
|
||||||
|
}
|
||||||
|
|
||||||
|
base.OnFrameworkInitializationCompleted();
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
PhotoboothUploader/Assets/app.ico
Normal file
BIN
PhotoboothUploader/Assets/app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
BIN
PhotoboothUploader/Assets/logo.png
Normal file
BIN
PhotoboothUploader/Assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 344 KiB |
BIN
PhotoboothUploader/Assets/sample-upload.png
Normal file
BIN
PhotoboothUploader/Assets/sample-upload.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
142
PhotoboothUploader/MainWindow.axaml
Normal file
142
PhotoboothUploader/MainWindow.axaml
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
|
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||||
|
x:Class="PhotoboothUploader.MainWindow"
|
||||||
|
Width="560" Height="420"
|
||||||
|
MinWidth="520" MinHeight="400"
|
||||||
|
Title="AI Stylegallery - Photobooth Uploader">
|
||||||
|
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||||
|
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||||
|
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
|
||||||
|
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
|
</Border>
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock x:Name="TitleText"
|
||||||
|
Text="AI Stylegallery - Photobooth Uploader"
|
||||||
|
Classes="title" />
|
||||||
|
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Text="Zugangsdaten" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Upload-URL" />
|
||||||
|
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://stylegallery.fotospiel.app/api/v1/photobooth/upload" />
|
||||||
|
<TextBlock Text="Benutzername" />
|
||||||
|
<TextBox x:Name="ManualUsernameBox" />
|
||||||
|
<TextBlock Text="Passwort" />
|
||||||
|
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
|
||||||
|
<TextBlock Text="Antwort-Format (optional)" />
|
||||||
|
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
|
||||||
|
<ComboBoxItem Content="Auto" />
|
||||||
|
<ComboBoxItem Content="JSON" />
|
||||||
|
<ComboBoxItem Content="XML" />
|
||||||
|
</ComboBox>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
<Button x:Name="SparkboothPresetButton" Content="Photobooth (Sparkbooth)" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" Classes="primary" IsEnabled="False" />
|
||||||
|
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" Classes="secondary" IsEnabled="False" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Upload-Optionen" FontWeight="SemiBold" />
|
||||||
|
<TextBlock Text="Max. parallele Uploads" />
|
||||||
|
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
|
||||||
|
<TextBlock Text="Upload-Tempo" />
|
||||||
|
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
|
||||||
|
<ComboBoxItem Content="Schnell (ohne Pause)" />
|
||||||
|
<ComboBoxItem Content="Normal" />
|
||||||
|
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock Text="Nur diese Dateien (optional)" />
|
||||||
|
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
|
||||||
|
<TextBlock Text="Dateien ausschliessen (optional)" />
|
||||||
|
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
|
||||||
|
<TextBlock Text="Profile" />
|
||||||
|
<ComboBox x:Name="ProfilesBox" />
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
|
||||||
|
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||||
|
<Border Padding="14" Classes="card accent">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Status" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
|
||||||
|
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
|
||||||
|
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||||
|
<TextBlock x:Name="EventNameText" Text="Benutzername: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="UploadUrlText" Text="Upload-URL: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||||
|
<TextBlock x:Name="ConnectExpiryText" Text="Antwort-Format: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||||
|
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||||
|
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Border Padding="14" Classes="card">
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||||
|
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
||||||
|
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
||||||
|
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
1158
PhotoboothUploader/MainWindow.axaml.cs
Normal file
1158
PhotoboothUploader/MainWindow.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
33
PhotoboothUploader/Models/PhotoboothConnectResponse.cs
Normal file
33
PhotoboothUploader/Models/PhotoboothConnectResponse.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("data")]
|
||||||
|
public PhotoboothConnectPayload? Data { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("message")]
|
||||||
|
public string? Message { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectPayload
|
||||||
|
{
|
||||||
|
[JsonPropertyName("event_name")]
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("upload_url")]
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("username")]
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("password")]
|
||||||
|
public string? Password { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_at")]
|
||||||
|
public string? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("response_format")]
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
}
|
||||||
26
PhotoboothUploader/Models/PhotoboothProfile.cs
Normal file
26
PhotoboothUploader/Models/PhotoboothProfile.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothProfile
|
||||||
|
{
|
||||||
|
public string? Label { get; set; }
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
public string? WatchFolder { get; set; }
|
||||||
|
public string? IncludePatterns { get; set; }
|
||||||
|
public string? ExcludePatterns { get; set; }
|
||||||
|
public int MaxConcurrentUploads { get; set; } = 2;
|
||||||
|
public int UploadDelayMs { get; set; } = 500;
|
||||||
|
|
||||||
|
public string DisplayName
|
||||||
|
=> !string.IsNullOrWhiteSpace(Label)
|
||||||
|
? Label
|
||||||
|
: !string.IsNullOrWhiteSpace(EventName)
|
||||||
|
? EventName
|
||||||
|
: UploadUrl ?? BaseUrl ?? "Profil";
|
||||||
|
}
|
||||||
29
PhotoboothUploader/Models/PhotoboothSettings.cs
Normal file
29
PhotoboothUploader/Models/PhotoboothSettings.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public sealed class PhotoboothSettings
|
||||||
|
{
|
||||||
|
public string? BaseUrl { get; set; }
|
||||||
|
public string? EventName { get; set; }
|
||||||
|
public string? UploadUrl { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Password { get; set; }
|
||||||
|
public string? ResponseFormat { get; set; }
|
||||||
|
public string? WatchFolder { get; set; }
|
||||||
|
public string? IncludePatterns { get; set; }
|
||||||
|
public string? ExcludePatterns { get; set; }
|
||||||
|
public List<string> PendingUploads { get; set; } = new();
|
||||||
|
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
public List<PhotoboothProfile> Profiles { get; set; } = new();
|
||||||
|
public string? ConnectExpiresAt { get; set; }
|
||||||
|
public string? LastSeenFile { get; set; }
|
||||||
|
public string? LastSeenAt { get; set; }
|
||||||
|
public string? LastError { get; set; }
|
||||||
|
public string? LastErrorAt { get; set; }
|
||||||
|
public int MaxConcurrentUploads { get; set; } = 2;
|
||||||
|
public int UploadDelayMs { get; set; } = 500;
|
||||||
|
public double WindowWidth { get; set; }
|
||||||
|
public double WindowHeight { get; set; }
|
||||||
|
}
|
||||||
74
PhotoboothUploader/Models/UploadItem.cs
Normal file
74
PhotoboothUploader/Models/UploadItem.cs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
public enum UploadStatus
|
||||||
|
{
|
||||||
|
Queued,
|
||||||
|
Uploading,
|
||||||
|
Success,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class UploadItem : INotifyPropertyChanged
|
||||||
|
{
|
||||||
|
private UploadStatus _status;
|
||||||
|
private DateTimeOffset _updatedAt;
|
||||||
|
|
||||||
|
public UploadItem(string path)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
FileName = System.IO.Path.GetFileName(path);
|
||||||
|
UpdatedAt = DateTimeOffset.Now;
|
||||||
|
Status = UploadStatus.Queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Path { get; }
|
||||||
|
|
||||||
|
public string FileName { get; }
|
||||||
|
|
||||||
|
public UploadStatus Status
|
||||||
|
{
|
||||||
|
get => _status;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
if (_status != value)
|
||||||
|
{
|
||||||
|
_status = value;
|
||||||
|
UpdatedAt = DateTimeOffset.Now;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAt
|
||||||
|
{
|
||||||
|
get => _updatedAt;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
_updatedAt = value;
|
||||||
|
OnPropertyChanged();
|
||||||
|
OnPropertyChanged(nameof(UpdatedLabel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string StatusLabel => Status switch
|
||||||
|
{
|
||||||
|
UploadStatus.Uploading => "Upload läuft",
|
||||||
|
UploadStatus.Success => "Fertig",
|
||||||
|
UploadStatus.Failed => "Fehlgeschlagen",
|
||||||
|
_ => "Wartet",
|
||||||
|
};
|
||||||
|
|
||||||
|
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
|
||||||
|
|
||||||
|
public event PropertyChangedEventHandler? PropertyChanged;
|
||||||
|
|
||||||
|
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||||
|
{
|
||||||
|
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||||
|
}
|
||||||
|
}
|
||||||
31
PhotoboothUploader/PhotoboothUploader.csproj
Normal file
31
PhotoboothUploader/PhotoboothUploader.csproj
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net9.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>AIStylegallery.PhotoboothUploader</AssemblyName>
|
||||||
|
<Product>AI Stylegallery Photobooth Uploader</Product>
|
||||||
|
<Company>AI Stylegallery</Company>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
|
||||||
|
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||||
|
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
|
||||||
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="Assets\app.ico" />
|
||||||
|
<AvaloniaResource Include="Assets\logo.png" />
|
||||||
|
<AvaloniaResource Include="Assets\sample-upload.png" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
21
PhotoboothUploader/Program.cs
Normal file
21
PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||||
|
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||||
|
// yet and stuff might break.
|
||||||
|
[STAThread]
|
||||||
|
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||||
|
.StartWithClassicDesktopLifetime(args);
|
||||||
|
|
||||||
|
// Avalonia configuration, don't remove; also used by visual designer.
|
||||||
|
public static AppBuilder BuildAvaloniaApp()
|
||||||
|
=> AppBuilder.Configure<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
122
PhotoboothUploader/Services/PhotoboothConnectClient.cs
Normal file
122
PhotoboothUploader/Services/PhotoboothConnectClient.cs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class PhotoboothConnectClient
|
||||||
|
{
|
||||||
|
private const int MaxRetries = 2;
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||||
|
{
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri(baseUrl),
|
||||||
|
Timeout = DefaultTimeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var request = new { code };
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||||
|
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
if (attempt < MaxRetries)
|
||||||
|
{
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Fail("Verbindung fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content.Headers.ContentLength == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||||
|
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||||
|
or HttpStatusCode.InternalServerError;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PhotoboothConnectResponse Fail(string message)
|
||||||
|
{
|
||||||
|
return new PhotoboothConnectResponse
|
||||||
|
{
|
||||||
|
Message = message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
PhotoboothUploader/Services/SettingsStore.cs
Normal file
47
PhotoboothUploader/Services/SettingsStore.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class SettingsStore
|
||||||
|
{
|
||||||
|
private readonly JsonSerializerOptions _options = new()
|
||||||
|
{
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public string SettingsPath { get; }
|
||||||
|
public string LogPath { get; }
|
||||||
|
|
||||||
|
public SettingsStore()
|
||||||
|
{
|
||||||
|
var basePath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"AIStylegallery",
|
||||||
|
"PhotoboothUploader");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(basePath);
|
||||||
|
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||||
|
LogPath = Path.Combine(basePath, "uploader.log");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PhotoboothSettings Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(SettingsPath))
|
||||||
|
{
|
||||||
|
return new PhotoboothSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = File.ReadAllText(SettingsPath);
|
||||||
|
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(settings, _options);
|
||||||
|
File.WriteAllText(SettingsPath, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
297
PhotoboothUploader/Services/UploadService.cs
Normal file
297
PhotoboothUploader/Services/UploadService.cs
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using PhotoboothUploader.Models;
|
||||||
|
|
||||||
|
namespace PhotoboothUploader.Services;
|
||||||
|
|
||||||
|
public sealed class UploadService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||||
|
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
|
||||||
|
private const int MaxRetries = 2;
|
||||||
|
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
private string _userAgent = "AIStylegalleryPhotoboothUploader";
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private readonly List<Task> _workers = new();
|
||||||
|
|
||||||
|
public void Configure(string userAgent)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||||
|
{
|
||||||
|
_userAgent = userAgent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Start(
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
Action<string> onQueued,
|
||||||
|
Action<string> onUploading,
|
||||||
|
Action<string> onSuccess,
|
||||||
|
Action<string, string> onFailure)
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
var workerCount = GetWorkerCount(settings);
|
||||||
|
for (var i = 0; i < workerCount; i++)
|
||||||
|
{
|
||||||
|
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop()
|
||||||
|
{
|
||||||
|
_cts?.Cancel();
|
||||||
|
_cts = null;
|
||||||
|
_pending.Clear();
|
||||||
|
_workers.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Enqueue(string path, Action<string> onQueued)
|
||||||
|
{
|
||||||
|
if (!_pending.TryAdd(path, 0))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_queue.Writer.TryWrite(path);
|
||||||
|
onQueued(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WorkerAsync(
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
Action<string> onQueued,
|
||||||
|
Action<string> onUploading,
|
||||||
|
Action<string> onSuccess,
|
||||||
|
Action<string, string> onFailure,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var client = new HttpClient();
|
||||||
|
client.Timeout = DefaultTimeout;
|
||||||
|
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
|
||||||
|
while (await _queue.Reader.WaitToReadAsync(token))
|
||||||
|
{
|
||||||
|
while (_queue.Reader.TryRead(out var path))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
onUploading(path);
|
||||||
|
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||||
|
if (error is null)
|
||||||
|
{
|
||||||
|
onSuccess(path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onFailure(path, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pending.TryRemove(path, out _);
|
||||||
|
if (settings.UploadDelayMs > 0)
|
||||||
|
{
|
||||||
|
await Task.Delay(settings.UploadDelayMs, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> UploadWithRetryAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||||
|
{
|
||||||
|
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||||
|
if (attemptError.Success)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||||
|
{
|
||||||
|
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(GetRetryDelay(attempt), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Upload fehlgeschlagen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<UploadAttempt> UploadOnceAsync(
|
||||||
|
HttpClient client,
|
||||||
|
PhotoboothSettings settings,
|
||||||
|
string path,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
var readyError = await WaitForFileReadyAsync(path, token);
|
||||||
|
if (readyError is not null)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail(readyError, retryable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var content = new MultipartFormDataContent();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.Username))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.Username), "username");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.Password))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.Password), "password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
|
||||||
|
{
|
||||||
|
content.Add(new StringContent(settings.ResponseFormat), "response_format");
|
||||||
|
}
|
||||||
|
|
||||||
|
var stream = File.OpenRead(path);
|
||||||
|
var fileContent = new StreamContent(stream);
|
||||||
|
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||||
|
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||||
|
|
||||||
|
if (response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await ReadResponseBodyAsync(response, token);
|
||||||
|
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||||
|
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}";
|
||||||
|
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException) when (!token.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
|
||||||
|
{
|
||||||
|
var lastSize = -1L;
|
||||||
|
|
||||||
|
for (var attempts = 0; attempts < 10; attempts++)
|
||||||
|
{
|
||||||
|
token.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
await Task.Delay(500, token);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var info = new FileInfo(path);
|
||||||
|
var size = info.Length;
|
||||||
|
|
||||||
|
if (size > 0 && size == lastSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSize = size;
|
||||||
|
await Task.Delay(700, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Datei ist noch in Bearbeitung.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ResolveContentType(string path)
|
||||||
|
{
|
||||||
|
return Path.GetExtension(path)?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
|
||||||
|
{
|
||||||
|
var numeric = (int)statusCode;
|
||||||
|
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimeSpan GetRetryDelay(int attempt)
|
||||||
|
{
|
||||||
|
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
|
||||||
|
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
|
||||||
|
{
|
||||||
|
if (response.Content is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync(token);
|
||||||
|
if (string.IsNullOrWhiteSpace(body))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
body = body.Trim();
|
||||||
|
return body.Length > 200 ? body[..200] + "…" : body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int GetWorkerCount(PhotoboothSettings settings)
|
||||||
|
{
|
||||||
|
var count = settings.MaxConcurrentUploads;
|
||||||
|
if (count < 1)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 5 ? 5 : count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
|
||||||
|
{
|
||||||
|
public static UploadAttempt Ok() => new(true, false, null);
|
||||||
|
|
||||||
|
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
PhotoboothUploader/app.manifest
Normal file
18
PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<!-- This manifest is used on Windows only.
|
||||||
|
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||||
|
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="AIStylegallery.PhotoboothUploader"/>
|
||||||
|
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<!-- A list of the Windows versions that this application has been tested on
|
||||||
|
and is designed to work with. Uncomment the appropriate elements
|
||||||
|
and Windows will automatically select the most compatible environment. -->
|
||||||
|
|
||||||
|
<!-- Windows 10 -->
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
74
app/Filament/Pages/Auth/EditProfile.php
Normal file
74
app/Filament/Pages/Auth/EditProfile.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
|
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Component;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class EditProfile extends BaseEditProfile
|
||||||
|
{
|
||||||
|
protected int $maxPinLength = 8;
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
$this->getNameFormComponent(),
|
||||||
|
$this->getEmailFormComponent(),
|
||||||
|
$this->getPasswordFormComponent(),
|
||||||
|
$this->getPasswordConfirmationFormComponent(),
|
||||||
|
$this->getCurrentPasswordFormComponent(),
|
||||||
|
$this->getAdminPinFormComponent(),
|
||||||
|
$this->getRemoveAdminPinFormComponent(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getAdminPinFormComponent(): Component
|
||||||
|
{
|
||||||
|
return TextInput::make('admin_pin')
|
||||||
|
->label('Admin PIN')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->afterStateHydrated(function (TextInput $component): void {
|
||||||
|
$component->state(null);
|
||||||
|
})
|
||||||
|
->disabled(fn (Get $get): bool => (bool) $get('remove_admin_pin'))
|
||||||
|
->helperText('Leave blank to keep the current PIN. Use only digits.')
|
||||||
|
->rule('regex:/^\\d+$/')
|
||||||
|
->minLength(4)
|
||||||
|
->maxLength($this->maxPinLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRemoveAdminPinFormComponent(): Component
|
||||||
|
{
|
||||||
|
return Toggle::make('remove_admin_pin')
|
||||||
|
->label('Remove admin PIN')
|
||||||
|
->helperText('Clears the PIN so this user no longer appears on the kiosk login.')
|
||||||
|
->default(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
protected function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$pin = (string) ($data['admin_pin'] ?? '');
|
||||||
|
$removePin = (bool) ($data['remove_admin_pin'] ?? false);
|
||||||
|
|
||||||
|
if ($removePin) {
|
||||||
|
$data['admin_pin_hash'] = null;
|
||||||
|
} elseif ($pin !== '') {
|
||||||
|
$data['admin_pin_hash'] = Hash::make($pin);
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['admin_pin'], $data['remove_admin_pin']);
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
196
app/Filament/Pages/Auth/Login.php
Normal file
196
app/Filament/Pages/Auth/Login.php
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
|
||||||
|
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
|
||||||
|
use Filament\Auth\Pages\Login as BaseLogin;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Hidden;
|
||||||
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
|
use Filament\Schemas\Components\Component;
|
||||||
|
use Filament\Schemas\Components\View;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class Login extends BaseLogin
|
||||||
|
{
|
||||||
|
protected int $maxPinLength = 8;
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Hidden::make('email')
|
||||||
|
->rules(fn (): array => $this->shouldShowPinLogin() ? ['required', 'email'] : [])
|
||||||
|
->visible(fn (): bool => $this->shouldShowPinLogin())
|
||||||
|
->dehydrated(fn (): bool => $this->shouldShowPinLogin())
|
||||||
|
->live(),
|
||||||
|
Hidden::make('pin')
|
||||||
|
->rules(fn (): array => $this->shouldShowPinLogin() ? [
|
||||||
|
'nullable',
|
||||||
|
'min:4',
|
||||||
|
"max:{$this->maxPinLength}",
|
||||||
|
'regex:/^\\d+$/',
|
||||||
|
] : [])
|
||||||
|
->visible(fn (): bool => $this->shouldShowPinLogin())
|
||||||
|
->dehydrated(fn (): bool => $this->shouldShowPinLogin())
|
||||||
|
->live(),
|
||||||
|
$this->getEmailFormComponent(),
|
||||||
|
$this->getPasswordFormComponent(),
|
||||||
|
$this->getRememberFormComponent(),
|
||||||
|
View::make('filament.pages.auth.kiosk-login')
|
||||||
|
->viewData([
|
||||||
|
'hasPinUsers' => $this->shouldShowPinLogin(),
|
||||||
|
'users' => $this->getKioskUsers(),
|
||||||
|
'maxPinLength' => $this->maxPinLength,
|
||||||
|
])
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(): ?LoginResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->rateLimit(5);
|
||||||
|
} catch (TooManyRequestsException $exception) {
|
||||||
|
$this->getRateLimitedNotification($exception)?->send();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
if (blank($data['pin'] ?? null)) {
|
||||||
|
return parent::authenticate();
|
||||||
|
}
|
||||||
|
|
||||||
|
$authGuard = Filament::auth();
|
||||||
|
$credentials = [
|
||||||
|
'email' => $data['email'] ?? null,
|
||||||
|
'password' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
$user = User::query()
|
||||||
|
->where('email', $data['email'] ?? '')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$pin = (string) ($data['pin'] ?? '');
|
||||||
|
|
||||||
|
if (
|
||||||
|
(! $user)
|
||||||
|
|| blank($user->admin_pin_hash)
|
||||||
|
|| $pin === ''
|
||||||
|
|| (! ctype_digit($pin))
|
||||||
|
|| (strlen($pin) < 4 || strlen($pin) > $this->maxPinLength)
|
||||||
|
|| (! Hash::check($pin, $user->admin_pin_hash))
|
||||||
|
) {
|
||||||
|
$this->userUndertakingMultiFactorAuthentication = null;
|
||||||
|
|
||||||
|
$this->fireFailedEvent($authGuard, $user, $credentials);
|
||||||
|
$this->throwFailureValidationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
filled($this->userUndertakingMultiFactorAuthentication) &&
|
||||||
|
(decrypt($this->userUndertakingMultiFactorAuthentication) === $user->getAuthIdentifier())
|
||||||
|
) {
|
||||||
|
$this->multiFactorChallengeForm->validate();
|
||||||
|
} else {
|
||||||
|
foreach (Filament::getMultiFactorAuthenticationProviders() as $multiFactorAuthenticationProvider) {
|
||||||
|
if (! $multiFactorAuthenticationProvider->isEnabled($user)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->userUndertakingMultiFactorAuthentication = encrypt($user->getAuthIdentifier());
|
||||||
|
|
||||||
|
if ($multiFactorAuthenticationProvider instanceof \Filament\Auth\MultiFactor\Contracts\HasBeforeChallengeHook) {
|
||||||
|
$multiFactorAuthenticationProvider->beforeChallenge($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filled($this->userUndertakingMultiFactorAuthentication)) {
|
||||||
|
$this->multiFactorChallengeForm->fill();
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user instanceof FilamentUser) {
|
||||||
|
if (! $user->canAccessPanel(Filament::getCurrentOrDefaultPanel())) {
|
||||||
|
$this->fireFailedEvent($authGuard, $user, $credentials);
|
||||||
|
$this->throwFailureValidationException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$authGuard->login($user, $data['remember'] ?? true);
|
||||||
|
session()->regenerate();
|
||||||
|
|
||||||
|
return app(LoginResponse::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectUser(int $userId): void
|
||||||
|
{
|
||||||
|
$user = $this->getKioskUsers()->firstWhere('id', $userId);
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data['email'] = $user->email;
|
||||||
|
$this->data['pin'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function appendPinDigit(int $digit): void
|
||||||
|
{
|
||||||
|
$pin = (string) ($this->data['pin'] ?? '');
|
||||||
|
|
||||||
|
if (strlen($pin) >= $this->maxPinLength) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data['pin'] = $pin.$digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletePinDigit(): void
|
||||||
|
{
|
||||||
|
$pin = (string) ($this->data['pin'] ?? '');
|
||||||
|
|
||||||
|
if ($pin === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->data['pin'] = substr($pin, 0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearPin(): void
|
||||||
|
{
|
||||||
|
$this->data['pin'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, User>
|
||||||
|
*/
|
||||||
|
protected function getKioskUsers(): Collection
|
||||||
|
{
|
||||||
|
return User::query()
|
||||||
|
->whereNotNull('admin_pin_hash')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(['id', 'name', 'email', 'admin_pin_hash']);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function shouldShowPinLogin(): bool
|
||||||
|
{
|
||||||
|
return $this->getKioskUsers()->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getPasswordFormComponent(): Component
|
||||||
|
{
|
||||||
|
return parent::getPasswordFormComponent()
|
||||||
|
->required(fn (): bool => blank($this->data['pin'] ?? null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ use Filament\Tables\Contracts\HasTable;
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class SparkboothConnections extends Page implements HasTable
|
class PhotoboothConnections extends Page implements HasTable
|
||||||
{
|
{
|
||||||
use InteractsWithTable;
|
use InteractsWithTable;
|
||||||
|
|
||||||
@@ -23,16 +23,16 @@ class SparkboothConnections extends Page implements HasTable
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 11;
|
protected static ?int $navigationSort = 11;
|
||||||
|
|
||||||
protected static ?string $title = 'Sparkbooth Verbindungen';
|
protected static ?string $title = 'Photobooth Verbindungen';
|
||||||
|
|
||||||
protected ?string $heading = 'Sparkbooth Verbindungen';
|
protected ?string $heading = 'Photobooth Verbindungen';
|
||||||
|
|
||||||
protected string $view = 'filament.pages.sparkbooth-connections';
|
protected string $view = 'filament.pages.photobooth-connections';
|
||||||
|
|
||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->heading('Vorhandene Sparkbooth-Verbindungen')
|
->heading('Vorhandene Photobooth-Verbindungen')
|
||||||
->query(
|
->query(
|
||||||
Gallery::query()
|
Gallery::query()
|
||||||
->whereNotNull('upload_token_hash')
|
->whereNotNull('upload_token_hash')
|
||||||
@@ -73,7 +73,7 @@ class SparkboothConnections extends Page implements HasTable
|
|||||||
$data = [
|
$data = [
|
||||||
'gallery' => $record->only(['id', 'name', 'slug', 'images_path']),
|
'gallery' => $record->only(['id', 'name', 'slug', 'images_path']),
|
||||||
'upload_token' => $plainToken,
|
'upload_token' => $plainToken,
|
||||||
'upload_url' => route('api.sparkbooth.upload'),
|
'upload_url' => route('api.photobooth.upload'),
|
||||||
'gallery_url' => route('gallery.show', $record),
|
'gallery_url' => route('gallery.show', $record),
|
||||||
'sparkbooth_username' => $record->sparkbooth_username,
|
'sparkbooth_username' => $record->sparkbooth_username,
|
||||||
'sparkbooth_password' => $record->sparkbooth_password,
|
'sparkbooth_password' => $record->sparkbooth_password,
|
||||||
@@ -86,26 +86,26 @@ class SparkboothConnections extends Page implements HasTable
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return view('filament.pages.partials.sparkbooth-token', $data);
|
return view('filament.pages.partials.photobooth-token', $data);
|
||||||
}),
|
}),
|
||||||
Action::make('deleteConnection')
|
Action::make('deleteConnection')
|
||||||
->label('Verbindung loeschen')
|
->label('Verbindung loeschen')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Sparkbooth-Verbindung entfernen')
|
->modalHeading('Photobooth-Verbindung entfernen')
|
||||||
->modalDescription('Die Galerie bleibt erhalten, aber Upload-Token und Zugangsdaten werden geloescht.')
|
->modalDescription('Die Galerie bleibt erhalten, aber Upload-Token und Zugangsdaten werden geloescht.')
|
||||||
->action(function (Gallery $record): void {
|
->action(function (Gallery $record): void {
|
||||||
$record->clearSparkboothConnection();
|
$record->clearSparkboothConnection();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Sparkbooth-Verbindung entfernt.')
|
->title('Photobooth-Verbindung entfernt.')
|
||||||
->body('Der Upload-Token und die Zugangsdaten wurden geloescht.')
|
->body('Der Upload-Token und die Zugangsdaten wurden geloescht.')
|
||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->emptyStateHeading('Keine Sparkbooth-Verbindungen')
|
->emptyStateHeading('Keine Photobooth-Verbindungen')
|
||||||
->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads fuer eine Galerie.');
|
->emptyStateDescription('Lege eine neue Verbindung an oder aktiviere Uploads fuer eine Galerie.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ use Filament\Schemas\Schema;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class SparkboothSetup extends Page implements HasForms
|
class PhotoboothSetup extends Page implements HasForms
|
||||||
{
|
{
|
||||||
use InteractsWithForms;
|
use InteractsWithForms;
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ class SparkboothSetup extends Page implements HasForms
|
|||||||
|
|
||||||
protected static ?int $navigationSort = 10;
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
protected string $view = 'filament.pages.sparkbooth-setup';
|
protected string $view = 'filament.pages.photobooth-setup';
|
||||||
|
|
||||||
public ?array $data = [];
|
public ?array $data = [];
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ class SparkboothSetup extends Page implements HasForms
|
|||||||
|
|
||||||
public function getTitle(): string
|
public function getTitle(): string
|
||||||
{
|
{
|
||||||
return 'Sparkbooth Setup';
|
return 'Photobooth Setup';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
@@ -64,8 +64,8 @@ class SparkboothSetup extends Page implements HasForms
|
|||||||
->label('Uploads aktivieren')
|
->label('Uploads aktivieren')
|
||||||
->default(true),
|
->default(true),
|
||||||
TextInput::make('sparkbooth_username')
|
TextInput::make('sparkbooth_username')
|
||||||
->label('Sparkbooth Benutzername')
|
->label('Photobooth Benutzername')
|
||||||
->helperText('Wird in Sparkbooth unter „Username“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
|
->helperText('Wird im Photobooth Uploader unter „Benutzername“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
|
||||||
->default(fn (): string => 'spark-'.Str::lower(Str::random(6)))
|
->default(fn (): string => 'spark-'.Str::lower(Str::random(6)))
|
||||||
->required()
|
->required()
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
@@ -73,7 +73,7 @@ class SparkboothSetup extends Page implements HasForms
|
|||||||
->unique(table: Gallery::class, column: 'sparkbooth_username'),
|
->unique(table: Gallery::class, column: 'sparkbooth_username'),
|
||||||
Select::make('sparkbooth_response_format')
|
Select::make('sparkbooth_response_format')
|
||||||
->label('Standard-Antwortformat')
|
->label('Standard-Antwortformat')
|
||||||
->helperText('Sparkbooth kann JSON oder XML erwarten. JSON ist empfohlen.')
|
->helperText('Der Photobooth Uploader kann JSON oder XML erwarten. JSON ist empfohlen.')
|
||||||
->options([
|
->options([
|
||||||
'json' => 'JSON',
|
'json' => 'JSON',
|
||||||
'xml' => 'XML',
|
'xml' => 'XML',
|
||||||
@@ -113,7 +113,7 @@ class SparkboothSetup extends Page implements HasForms
|
|||||||
$this->result = [
|
$this->result = [
|
||||||
'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']),
|
'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']),
|
||||||
'upload_token' => $plainToken,
|
'upload_token' => $plainToken,
|
||||||
'upload_url' => route('api.sparkbooth.upload'),
|
'upload_url' => route('api.photobooth.upload'),
|
||||||
'gallery_url' => route('gallery.show', $gallery),
|
'gallery_url' => route('gallery.show', $gallery),
|
||||||
'sparkbooth_username' => $sparkboothUsername,
|
'sparkbooth_username' => $sparkboothUsername,
|
||||||
'sparkbooth_password' => $sparkboothPassword,
|
'sparkbooth_password' => $sparkboothPassword,
|
||||||
@@ -79,14 +79,14 @@ class GalleryForm
|
|||||||
'url' => $record ? URL::route('gallery.show', $record) : null,
|
'url' => $record ? URL::route('gallery.show', $record) : null,
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
Tab::make('Sparkbooth')
|
Tab::make('Photobooth')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Sparkbooth Upload')
|
Section::make('Photobooth Upload')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('upload_enabled')
|
Toggle::make('upload_enabled')
|
||||||
->label('Uploads aktivieren')
|
->label('Uploads aktivieren')
|
||||||
->helperText('Steuert, ob Sparkbooth-Uploads erlaubt sind.')
|
->helperText('Steuert, ob Photobooth-Uploads erlaubt sind.')
|
||||||
->default(false),
|
->default(false),
|
||||||
Select::make('sparkbooth_response_format')
|
Select::make('sparkbooth_response_format')
|
||||||
->label('Standard-Antwortformat')
|
->label('Standard-Antwortformat')
|
||||||
@@ -96,25 +96,25 @@ class GalleryForm
|
|||||||
])
|
])
|
||||||
->default('json'),
|
->default('json'),
|
||||||
TextInput::make('sparkbooth_username')
|
TextInput::make('sparkbooth_username')
|
||||||
->label('Sparkbooth Benutzername')
|
->label('Photobooth Benutzername')
|
||||||
->helperText('Wird in Sparkbooth unter „Username“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
|
->helperText('Wird im Photobooth Uploader unter „Benutzername“ eingetragen. Erlaubt sind Buchstaben, Zahlen sowie ._-')
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
->rule('regex:/^[A-Za-z0-9._-]+$/')
|
->rule('regex:/^[A-Za-z0-9._-]+$/')
|
||||||
->unique(table: \App\Models\Gallery::class, column: 'sparkbooth_username', ignoreRecord: true)
|
->unique(table: \App\Models\Gallery::class, column: 'sparkbooth_username', ignoreRecord: true)
|
||||||
->dehydrateStateUsing(fn (?string $state): ?string => $state ? Str::of($state)->lower()->trim()->value() : null),
|
->dehydrateStateUsing(fn (?string $state): ?string => $state ? Str::of($state)->lower()->trim()->value() : null),
|
||||||
TextInput::make('sparkbooth_password')
|
TextInput::make('sparkbooth_password')
|
||||||
->label('Sparkbooth Passwort (neu)')
|
->label('Photobooth Passwort (neu)')
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->dehydrated(false)
|
->dehydrated(false)
|
||||||
->afterStateHydrated(fn (callable $set) => $set('sparkbooth_password', null))
|
->afterStateHydrated(fn (callable $set) => $set('sparkbooth_password', null))
|
||||||
->helperText('Leer lassen, um das bestehende Passwort zu behalten.'),
|
->helperText('Leer lassen, um das bestehende Passwort zu behalten.'),
|
||||||
]),
|
]),
|
||||||
View::make('filament.pages.partials.sparkbooth-token')
|
View::make('filament.pages.partials.photobooth-token')
|
||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->visible(fn (?object $record) => (bool) $record?->id)
|
->visible(fn (?object $record) => (bool) $record?->id)
|
||||||
->viewData(fn (?object $record) => [
|
->viewData(fn (?object $record) => [
|
||||||
'upload_url' => URL::route('api.sparkbooth.upload'),
|
'upload_url' => URL::route('api.photobooth.upload'),
|
||||||
'gallery_url' => $record ? URL::route('gallery.show', $record) : null,
|
'gallery_url' => $record ? URL::route('gallery.show', $record) : null,
|
||||||
'sparkbooth_username' => $record?->sparkbooth_username,
|
'sparkbooth_username' => $record?->sparkbooth_username,
|
||||||
'sparkbooth_password' => $record?->sparkbooth_password,
|
'sparkbooth_password' => $record?->sparkbooth_password,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\Users;
|
namespace App\Filament\Resources\Users;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\CreateAction;
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -16,7 +17,6 @@ use Filament\Schemas\Schema;
|
|||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
use BackedEnum;
|
|
||||||
|
|
||||||
class UserResource extends Resource
|
class UserResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -50,6 +50,20 @@ class UserResource extends Resource
|
|||||||
->dehydrated(fn (?string $state): bool => filled($state))
|
->dehydrated(fn (?string $state): bool => filled($state))
|
||||||
->required(fn (string $operation): bool => $operation === 'create')
|
->required(fn (string $operation): bool => $operation === 'create')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
|
TextInput::make('admin_pin_hash')
|
||||||
|
->label('Admin PIN')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->dehydrateStateUsing(fn (?string $state): ?string => filled($state) ? bcrypt($state) : null)
|
||||||
|
->dehydrated(fn (?string $state): bool => filled($state))
|
||||||
|
->afterStateHydrated(function (TextInput $component): void {
|
||||||
|
$component->state(null);
|
||||||
|
})
|
||||||
|
->nullable()
|
||||||
|
->rule('regex:/^\\d+$/')
|
||||||
|
->minLength(4)
|
||||||
|
->maxLength(8)
|
||||||
|
->helperText('Leave blank to keep the current PIN.'),
|
||||||
Select::make('role_id')
|
Select::make('role_id')
|
||||||
->label(__('filament.resource.user.form.role'))
|
->label(__('filament.resource.user.form.role'))
|
||||||
->relationship('role', 'name')
|
->relationship('role', 'name')
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ class SparkboothUploadController extends Controller
|
|||||||
$gallery,
|
$gallery,
|
||||||
$file,
|
$file,
|
||||||
$base64Payload,
|
$base64Payload,
|
||||||
$request->input('filename')
|
$request->input('filename'),
|
||||||
|
$request
|
||||||
);
|
);
|
||||||
} catch (RuntimeException $exception) {
|
} catch (RuntimeException $exception) {
|
||||||
return $this->respondError($request, $exception->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY, $gallery);
|
return $this->respondError($request, $exception->getMessage(), Response::HTTP_UNPROCESSABLE_ENTITY, $gallery);
|
||||||
@@ -177,7 +178,8 @@ class SparkboothUploadController extends Controller
|
|||||||
Gallery $gallery,
|
Gallery $gallery,
|
||||||
?UploadedFile $file,
|
?UploadedFile $file,
|
||||||
?string $base64Payload,
|
?string $base64Payload,
|
||||||
?string $preferredFilename
|
?string $preferredFilename,
|
||||||
|
Request $request
|
||||||
): array {
|
): array {
|
||||||
$directory = trim($gallery->images_path, '/');
|
$directory = trim($gallery->images_path, '/');
|
||||||
|
|
||||||
@@ -192,7 +194,7 @@ class SparkboothUploadController extends Controller
|
|||||||
|
|
||||||
$file->storeAs($directory, $filename, 'public');
|
$file->storeAs($directory, $filename, 'public');
|
||||||
|
|
||||||
return [$relativePath, asset('storage/'.$relativePath)];
|
return [$relativePath, $this->buildPublicUrl($relativePath, $request)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($base64Payload === null) {
|
if ($base64Payload === null) {
|
||||||
@@ -207,7 +209,7 @@ class SparkboothUploadController extends Controller
|
|||||||
throw new RuntimeException('Failed to store uploaded image.');
|
throw new RuntimeException('Failed to store uploaded image.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$relativePath, asset('storage/'.$relativePath)];
|
return [$relativePath, $this->buildPublicUrl($relativePath, $request)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,4 +350,13 @@ class SparkboothUploadController extends Controller
|
|||||||
|
|
||||||
return '<?xml version="1.0" encoding="UTF-8"?><rsp status="fail">'.$errorAttr.'</rsp>';
|
return '<?xml version="1.0" encoding="UTF-8"?><rsp status="fail">'.$errorAttr.'</rsp>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildPublicUrl(string $relativePath, Request $request): string
|
||||||
|
{
|
||||||
|
$relative = Str::start($relativePath, '/');
|
||||||
|
|
||||||
|
$root = config('app.url') ?: $request->getSchemeAndHttpHost();
|
||||||
|
|
||||||
|
return rtrim($root, '/').'/storage'.$relative;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'admin_pin_hash',
|
||||||
'role_id',
|
'role_id',
|
||||||
'email_notifications_enabled',
|
'email_notifications_enabled',
|
||||||
'theme_preference',
|
'theme_preference',
|
||||||
@@ -41,6 +42,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
*/
|
*/
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
|
'admin_pin_hash',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->default()
|
->default()
|
||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->viteTheme('resources/css/filament/admin/theme.css')
|
||||||
|
->login(\App\Filament\Pages\Auth\Login::class)
|
||||||
->brandLogo(fn () => new HtmlString(
|
->brandLogo(fn () => new HtmlString(
|
||||||
'<img src="'.asset('icon.png').'" alt="App Icon" style="height: 2.5rem; display: inline-block; vertical-align: middle; margin-right: 0.5rem;" />'.
|
'<img src="'.asset('icon.png').'" alt="App Icon" style="height: 2.5rem; display: inline-block; vertical-align: middle; margin-right: 0.5rem;" />'.
|
||||||
'<span style="vertical-align: middle; font-weight: bold; font-size: 1.25rem;">'.config('app.name').'</span>'
|
'<span style="vertical-align: middle; font-weight: bold; font-size: 1.25rem;">'.config('app.name').'</span>'
|
||||||
@@ -75,7 +76,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->plugins([
|
->plugins([
|
||||||
|
|
||||||
])
|
])
|
||||||
->profile(isSimple: false)
|
->profile(\App\Filament\Pages\Auth\EditProfile::class, isSimple: false)
|
||||||
->userMenuItems([
|
->userMenuItems([
|
||||||
MenuItem::make()
|
MenuItem::make()
|
||||||
->label(fn () => 'English')
|
->label(fn () => 'English')
|
||||||
|
|||||||
1204
composer.lock
generated
1204
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->string('admin_pin_hash')->nullable()->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('admin_pin_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
16
git-sync.bat
Normal file
16
git-sync.bat
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD') do set "BRANCH=%%b"
|
||||||
|
|
||||||
|
if "%BRANCH%"=="" (
|
||||||
|
echo Unable to determine current branch.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
git pull --ff-only origin "%BRANCH%"
|
||||||
|
if errorlevel 1 (
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Git synchronization complete.
|
||||||
689
package-lock.json
generated
689
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,9 +21,10 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
"@fortawesome/fontawesome-svg-core": "^7.0.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
"@fortawesome/free-solid-svg-icons": "^7.0.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.1.1",
|
"@fortawesome/vue-fontawesome": "^3.1.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "^4.45.1",
|
"@rollup/rollup-win32-x64-msvc": "^4.55.1",
|
||||||
"laravel-echo": "^2.1.7",
|
"laravel-echo": "^2.1.7",
|
||||||
"pusher-js": "^8.4.0",
|
"pusher-js": "^8.4.0",
|
||||||
"vanilla-lazyload": "^19.1.3"
|
"vanilla-lazyload": "^19.1.3",
|
||||||
|
"ziggy-js": "^1.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
function r({initialHeight:t,shouldAutosize:i,state:s}){return{state:s,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=t+"rem")},resize(){if(this.setInitialHeight(),this.$el.scrollHeight<=0)return;let e=this.$el.scrollHeight+"px";this.wrapperEl.style.height!==e&&(this.wrapperEl.style.height=e)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{r as default};
|
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
function u({activeTab:a,isTabPersistedInQueryString:e,livewireId:h,tab:o,tabQueryStringKey:s}){return{tab:o,init(){let t=this.getTabs(),i=new URLSearchParams(window.location.search);e&&i.has(s)&&t.includes(i.get(s))&&(this.tab=i.get(s)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[a-1]),Livewire.hook("commit",({component:r,commit:f,succeed:c,fail:l,respond:b})=>{c(({snapshot:d,effect:m})=>{this.$nextTick(()=>{if(r.id!==h)return;let n=this.getTabs();n.includes(this.tab)||(this.tab=n[a-1]??this.tab)})})})},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!e)return;let t=new URL(window.location.href);t.searchParams.set(s,this.tab),history.replaceState(null,document.title,t.toString())}}}export{u as default};
|
function I({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:c}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(c)&&t.includes(e.get(c))&&(this.tab=e.get(c)),this.$watch("tab",()=>this.updateQueryString()),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),Livewire.hook("commit",({component:n,commit:d,succeed:r,fail:h,respond:u})=>{r(({snapshot:p,effect:i})=>{this.$nextTick(()=>{if(n.id!==g)return;let s=this.getTabs();s.includes(this.tab)||(this.tab=s[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,n,d,r,h){let u=t.map(i=>Math.ceil(i.clientWidth)),p=t.map(i=>{let s=i.querySelector(".fi-tabs-item-label"),a=i.querySelector(".fi-badge"),o=Math.ceil(s.clientWidth),l=a?Math.ceil(a.clientWidth):0;return{label:o,badge:l,total:o+(l>0?d+l:0)}});for(let i=0;i<t.length;i++){let s=u.slice(0,i+1).reduce((b,y)=>b+y,0),a=i*n,o=p.slice(i+1),l=o.length>0,W=l?Math.max(...o.map(b=>b.total)):0,D=l?r+W+d+h+n:0;if(s+a+D>e)return i}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(c,this.tab),history.replaceState(null,document.title,t.toString())},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),n=Array.from(t.children).slice(0,-1),d=n.map(a=>a.style.display);n.forEach(a=>a.style.display=""),t.offsetHeight;let r=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),p=this.calculateTabItemGap(n[0]),i=this.calculateTabItemPadding(n[0]),s=this.findOverflowIndex(n,r,h,p,i,u);n.forEach((a,o)=>a.style.display=d[o]),s!==-1&&(this.withinDropdownIndex=s),this.withinDropdownMounted=!0},destroy(){this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{I as default};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
31
resources/css/filament/admin/theme.css
Normal file
31
resources/css/filament/admin/theme.css
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
@import '../../../../vendor/filament/filament/resources/css/theme.css';
|
||||||
|
|
||||||
|
@source '../../../../app/Filament/**/*';
|
||||||
|
@source '../../../../resources/views/filament/**/*';
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.fi-btn {
|
||||||
|
@apply px-5 py-3 text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi-icon-btn {
|
||||||
|
@apply p-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi-input-wrp {
|
||||||
|
@apply rounded-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.fi-input {
|
||||||
|
@apply px-4 py-3 text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi-input-wrp-prefix,
|
||||||
|
.fi-input-wrp-suffix {
|
||||||
|
@apply gap-x-4 ps-4 pe-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fi-input-wrp-label {
|
||||||
|
@apply text-base;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
|
|
||||||
<div v-if="!showStyleSelectorView" class="space-y-3">
|
<div v-if="!showStyleSelectorView" class="space-y-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="props.allowAiStyles"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left font-semibold text-slate-900 transition focus:outline-none focus-visible:ring-2 dark:text-white"
|
class="flex w-full items-center justify-between gap-3 rounded-2xl border px-4 py-3 text-left font-semibold text-slate-900 transition focus:outline-none focus-visible:ring-2 dark:text-white"
|
||||||
:class="[effectiveAiAvailable ? 'border-white/20 bg-white/40 hover:border-emerald-400 hover:bg-white/70 dark:border-white/10 dark:bg-white/5' : 'border-rose-200 bg-rose-50 cursor-not-allowed opacity-70']"
|
:class="[effectiveAiAvailable ? 'border-white/20 bg-white/40 hover:border-emerald-400 hover:bg-white/70 dark:border-white/10 dark:bg-white/5' : 'border-rose-200 bg-rose-50 cursor-not-allowed opacity-70']"
|
||||||
|
|||||||
@@ -1,43 +1,58 @@
|
|||||||
<template>
|
<template>
|
||||||
<Head title="Start" />
|
<Head :title="props.galleryHeading" />
|
||||||
<div class="min-h-screen bg-gradient-to-br from-slate-100 via-white to-slate-200 text-slate-900 transition-colors duration-500 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-slate-100">
|
<div class="relative min-h-screen bg-gradient-to-br from-slate-100 via-white to-slate-200 text-slate-900 transition-colors duration-500 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 dark:text-slate-100">
|
||||||
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-2 py-8 sm:px-4 lg:px-6">
|
|
||||||
<header class="rounded-3xl border border-slate-200 bg-white/90 p-6 text-slate-900 shadow-2xl backdrop-blur transition-colors duration-300 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">Live Gallery</p>
|
|
||||||
<h1 class="mt-1 text-3xl font-semibold text-slate-900 dark:text-white sm:text-4xl">
|
|
||||||
{{ props.galleryHeading }}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-emerald-400 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 disabled:opacity-60 dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:text-emerald-200"
|
class="absolute left-2 top-2 z-20 h-10 w-10 opacity-0"
|
||||||
|
@click="handleAdminHotzoneTap"
|
||||||
|
aria-label="Admin access"
|
||||||
|
></button>
|
||||||
|
<a
|
||||||
|
v-if="showAdminLink"
|
||||||
|
href="/admin"
|
||||||
|
class="fixed left-6 top-6 z-[120] rounded-full border border-slate-200 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.3em] text-slate-700 shadow-lg backdrop-blur transition hover:border-emerald-300 hover:text-emerald-600 dark:border-white/20 dark:bg-white/10 dark:text-white"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</a>
|
||||||
|
<div class="mx-auto flex w-full max-w-7xl flex-col gap-6 px-2 py-8 sm:px-4 lg:px-6">
|
||||||
|
<header class="relative rounded-3xl border border-slate-200 bg-white/90 p-6 text-slate-900 shadow-2xl backdrop-blur transition-colors duration-300 dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||||
|
<div class="absolute right-4 top-4 z-10 flex items-center gap-3 sm:right-6 sm:top-6">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-900 shadow-sm transition hover:border-emerald-400 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 disabled:opacity-60 dark:border-white/20 dark:bg-white/10 dark:text-white dark:hover:text-emerald-200"
|
||||||
@click="handleManualRefresh"
|
@click="handleManualRefresh"
|
||||||
:disabled="isRefreshing"
|
:disabled="isRefreshing"
|
||||||
|
:title="isRefreshing ? 'Aktualisiere…' : 'Jetzt aktualisieren'"
|
||||||
>
|
>
|
||||||
<font-awesome-icon :icon="['fas', 'arrows-rotate']" class="h-4 w-4" :class="{ 'animate-spin': isRefreshing }" />
|
<font-awesome-icon :icon="['fas', 'arrows-rotate']" class="h-5 w-5" :class="{ 'animate-spin': isRefreshing }" />
|
||||||
<span>{{ isRefreshing ? 'Aktualisiere…' : 'Jetzt aktualisieren' }}</span>
|
<span class="sr-only">{{ isRefreshing ? 'Aktualisiere…' : 'Jetzt aktualisieren' }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-900 shadow-sm transition hover:border-cyan-400 hover:text-cyan-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:text-cyan-200"
|
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-300 bg-white text-slate-900 shadow-sm transition hover:border-cyan-400 hover:text-cyan-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:text-cyan-200"
|
||||||
@click="toggleTheme"
|
@click="toggleTheme"
|
||||||
|
:title="currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode')"
|
||||||
>
|
>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="currentTheme === 'light' ? ['fas', 'moon'] : ['fas', 'sun']"
|
:icon="currentTheme === 'light' ? ['fas', 'moon'] : ['fas', 'sun']"
|
||||||
class="h-4 w-4"
|
class="h-5 w-5"
|
||||||
/>
|
/>
|
||||||
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
<span class="sr-only">{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!effectiveAiAvailable"
|
v-if="allowAiStyles && !aiAvailable"
|
||||||
class="inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200"
|
class="absolute right-4 top-16 z-10 inline-flex items-center gap-2 rounded-full border border-rose-200 bg-rose-50 px-3 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-rose-700 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-200 sm:right-6 sm:top-20"
|
||||||
>
|
>
|
||||||
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
||||||
<span>AI offline</span>
|
<span>AI offline</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex flex-col gap-3 sm:gap-4">
|
||||||
|
<div class="pr-20 sm:pr-0 sm:pt-1">
|
||||||
|
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">Live Gallery</p>
|
||||||
|
<h1 class="mt-1 text-3xl font-semibold text-slate-900 dark:text-white sm:text-4xl">
|
||||||
|
{{ props.galleryHeading }}
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-500 dark:text-slate-300">
|
<div class="mt-4 flex flex-wrap items-center gap-4 text-xs text-slate-500 dark:text-slate-300">
|
||||||
@@ -154,9 +169,16 @@ const lastRefreshedAt = ref(new Date());
|
|||||||
const refreshIntervalMs = ref(5000);
|
const refreshIntervalMs = ref(5000);
|
||||||
const toastMessage = ref(null);
|
const toastMessage = ref(null);
|
||||||
const toastVariant = ref('info');
|
const toastVariant = ref('info');
|
||||||
|
const showAdminLink = ref(false);
|
||||||
let toastTimer = null;
|
let toastTimer = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
let aiStatusTimer = null;
|
let aiStatusTimer = null;
|
||||||
|
let adminTapTimer = null;
|
||||||
|
let adminHideTimer = null;
|
||||||
|
let adminTapCount = 0;
|
||||||
|
const adminTapTarget = 5;
|
||||||
|
const adminTapWindowMs = 3000;
|
||||||
|
const adminRevealDurationMs = 15000;
|
||||||
|
|
||||||
const getImageIdentifier = (image) => image?.id ?? image?.image_id ?? null;
|
const getImageIdentifier = (image) => image?.id ?? image?.image_id ?? null;
|
||||||
|
|
||||||
@@ -315,6 +337,32 @@ const handleManualRefresh = () => {
|
|||||||
fetchImages({ silent: false });
|
fetchImages({ silent: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAdminHotzoneTap = () => {
|
||||||
|
adminTapCount += 1;
|
||||||
|
|
||||||
|
if (!adminTapTimer) {
|
||||||
|
adminTapTimer = setTimeout(() => {
|
||||||
|
adminTapCount = 0;
|
||||||
|
adminTapTimer = null;
|
||||||
|
}, adminTapWindowMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminTapCount >= adminTapTarget) {
|
||||||
|
adminTapCount = 0;
|
||||||
|
if (adminTapTimer) {
|
||||||
|
clearTimeout(adminTapTimer);
|
||||||
|
adminTapTimer = null;
|
||||||
|
}
|
||||||
|
showAdminLink.value = true;
|
||||||
|
if (adminHideTimer) {
|
||||||
|
clearTimeout(adminHideTimer);
|
||||||
|
}
|
||||||
|
adminHideTimer = setTimeout(() => {
|
||||||
|
showAdminLink.value = false;
|
||||||
|
}, adminRevealDurationMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const showContextMenu = (image) => {
|
const showContextMenu = (image) => {
|
||||||
selectedImage.value = image;
|
selectedImage.value = image;
|
||||||
currentOverlayComponent.value = 'contextMenu';
|
currentOverlayComponent.value = 'contextMenu';
|
||||||
@@ -637,5 +685,11 @@ onUnmounted(() => {
|
|||||||
if (aiStatusTimer) {
|
if (aiStatusTimer) {
|
||||||
clearInterval(aiStatusTimer);
|
clearInterval(aiStatusTimer);
|
||||||
}
|
}
|
||||||
|
if (adminTapTimer) {
|
||||||
|
clearTimeout(adminTapTimer);
|
||||||
|
}
|
||||||
|
if (adminHideTimer) {
|
||||||
|
clearTimeout(adminHideTimer);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import '../css/app.css';
|
|||||||
import { createApp, h } from 'vue';
|
import { createApp, h } from 'vue';
|
||||||
import { createInertiaApp } from '@inertiajs/vue3';
|
import { createInertiaApp } from '@inertiajs/vue3';
|
||||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||||
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
|
import { ZiggyVue } from 'ziggy-js/dist/vue.m';
|
||||||
|
|
||||||
/* Font Awesome imports */
|
/* Font Awesome imports */
|
||||||
import { library } from '@fortawesome/fontawesome-svg-core';
|
import { library } from '@fortawesome/fontawesome-svg-core';
|
||||||
@@ -42,7 +42,7 @@ library.add(
|
|||||||
faDownload,
|
faDownload,
|
||||||
);
|
);
|
||||||
|
|
||||||
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
|
const appName = import.meta.env.VITE_APP_NAME || 'Style Gallery';
|
||||||
|
|
||||||
const resolveTranslation = (source, path) => {
|
const resolveTranslation = (source, path) => {
|
||||||
if (!source || !path) {
|
if (!source || !path) {
|
||||||
@@ -59,7 +59,7 @@ const resolveTranslation = (source, path) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
title: (title) => `${title} - ${appName}`,
|
title: (title) => title || appName,
|
||||||
resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
|
resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
|
||||||
setup({ el, App, props, plugin }) {
|
setup({ el, App, props, plugin }) {
|
||||||
console.log('Inertia Page Props (app.js):', props.initialPage.props);
|
console.log('Inertia Page Props (app.js):', props.initialPage.props);
|
||||||
|
|||||||
137
resources/views/filament/pages/auth/kiosk-login.blade.php
Normal file
137
resources/views/filament/pages/auth/kiosk-login.blade.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
@php
|
||||||
|
$selectedEmail = $get('email');
|
||||||
|
$pin = (string) ($get('pin') ?? '');
|
||||||
|
$pinLength = strlen($pin);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="fi-grid" style="--cols-default: 1; gap: 1.5rem;">
|
||||||
|
@if (! $hasPinUsers)
|
||||||
|
<x-filament::empty-state
|
||||||
|
heading="No admin PINs yet"
|
||||||
|
description="Use the password login fields below to sign in, then set an Admin PIN in Users."
|
||||||
|
icon="heroicon-o-lock-closed"
|
||||||
|
icon-color="gray"
|
||||||
|
>
|
||||||
|
<x-slot:footer>
|
||||||
|
<x-filament::badge color="gray" size="lg">
|
||||||
|
Password login enabled
|
||||||
|
</x-filament::badge>
|
||||||
|
</x-slot:footer>
|
||||||
|
</x-filament::empty-state>
|
||||||
|
@else
|
||||||
|
<x-filament::section
|
||||||
|
heading="Admin selection"
|
||||||
|
description="Tap your name to select an account."
|
||||||
|
>
|
||||||
|
<div class="fi-grid fi-grid-ctn sm:fi-grid-cols lg:fi-grid-cols" style="--cols-default: 1; --cols-sm: 2; --cols-lg: 3; gap: 1rem;">
|
||||||
|
@foreach ($users as $user)
|
||||||
|
<div class="fi-grid-col">
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
wire:click="selectUser({{ $user->id }})"
|
||||||
|
:color="$selectedEmail === $user->email ? 'primary' : 'gray'"
|
||||||
|
:outlined="$selectedEmail !== $user->email"
|
||||||
|
size="lg"
|
||||||
|
style="width: 100%; text-align: left;"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div>{{ $user->name }}</div>
|
||||||
|
<div>{{ $user->email }}</div>
|
||||||
|
</div>
|
||||||
|
</x-filament::button>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
heading="PIN"
|
||||||
|
description="Enter the selected admin PIN to continue."
|
||||||
|
>
|
||||||
|
<x-slot:afterHeader>
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
color="gray"
|
||||||
|
outlined
|
||||||
|
size="sm"
|
||||||
|
wire:click="clearPin"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</x-filament::button>
|
||||||
|
</x-slot:afterHeader>
|
||||||
|
|
||||||
|
<x-filament::badge
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
style="font-size: 2.25rem; padding: 1.25rem 2rem; letter-spacing: 0.6rem;"
|
||||||
|
>
|
||||||
|
{{ $pinLength ? str_repeat('*', $pinLength) : '----' }}
|
||||||
|
</x-filament::badge>
|
||||||
|
|
||||||
|
<table class="fi-ta-table" style="margin-top: 1rem;">
|
||||||
|
<tbody>
|
||||||
|
@foreach ([[1, 2, 3], [4, 5, 6], [7, 8, 9]] as $row)
|
||||||
|
<tr>
|
||||||
|
@foreach ($row as $digit)
|
||||||
|
<td class="fi-ta-cell fi-align-center" style="width: 33%;">
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
wire:click="appendPinDigit({{ $digit }})"
|
||||||
|
size="xl"
|
||||||
|
color="gray"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
{{ $digit }}
|
||||||
|
</x-filament::button>
|
||||||
|
</td>
|
||||||
|
@endforeach
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
<tr>
|
||||||
|
<td class="fi-ta-cell fi-align-center" style="width: 33%;">
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
wire:click="clearPin"
|
||||||
|
size="xl"
|
||||||
|
color="gray"
|
||||||
|
outlined
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</x-filament::button>
|
||||||
|
</td>
|
||||||
|
<td class="fi-ta-cell fi-align-center" style="width: 33%;">
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
wire:click="appendPinDigit(0)"
|
||||||
|
size="xl"
|
||||||
|
color="gray"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</x-filament::button>
|
||||||
|
</td>
|
||||||
|
<td class="fi-ta-cell fi-align-center" style="width: 33%;">
|
||||||
|
<x-filament::button
|
||||||
|
type="button"
|
||||||
|
wire:click="deletePinDigit"
|
||||||
|
size="xl"
|
||||||
|
color="gray"
|
||||||
|
outlined
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</x-filament::button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
@error('data.email')
|
||||||
|
<x-filament::badge color="danger" size="lg">
|
||||||
|
{{ $message }}
|
||||||
|
</x-filament::badge>
|
||||||
|
@enderror
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@@ -16,14 +16,14 @@
|
|||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Benutzername</p>
|
||||||
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $username }}</p>
|
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $username }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Sparkbooth Custom Upload Dialog unter „Username“.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Benutzername“.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Passwort</p>
|
||||||
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $password }}</p>
|
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $password }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen unter „Password“.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Passwort“.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,14 +31,14 @@
|
|||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Antwortformat</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Antwortformat</p>
|
||||||
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ $format }}</p>
|
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ $format }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">In Sparkbooth „JSON Response“ oder „XML Response“ passend auswählen.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Im Photobooth Uploader das passende Antwort-Format auswählen.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Hinweise</p>
|
||||||
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
|
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<li>Uploader „Custom Upload“ wählen.</li>
|
<li>Uploader starten und Zugangsdaten eintragen.</li>
|
||||||
<li>Username & Password wie oben eintragen.</li>
|
<li>Username & Password wie oben eintragen.</li>
|
||||||
<li>Optional: Name/Email/Message Felder in Sparkbooth setzen.</li>
|
<li>Optional: Name/Email/Message Felder im Uploader setzen.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
|
|
||||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Benutzername</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Benutzername</p>
|
||||||
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_username'] }}</p>
|
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_username'] }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth ➜ Settings ➜ Upload ➜ Custom Upload ➜ Username.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Benutzername“.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Passwort</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Passwort</p>
|
||||||
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_password'] }}</p>
|
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['sparkbooth_password'] }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen in Sparkbooth unter „Password“.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Eintragen im Photobooth Uploader unter „Passwort“.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Standard-Antwortformat</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Standard-Antwortformat</p>
|
||||||
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($result['response_format']) }}</p>
|
<p class="mt-1 font-semibold text-gray-900 dark:text-white">{{ strtoupper($result['response_format']) }}</p>
|
||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit der Auswahl „JSON Response“ oder „XML Response“ in Sparkbooth übereinstimmen.</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Muss mit dem Antwort-Format im Photobooth Uploader übereinstimmen.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optional: Für bestehende Integrationen nutzbar (Feld „token“).</p>
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Optional: Für bestehende Integrationen nutzbar (Feld „token“).</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Hinweise</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Hinweise</p>
|
||||||
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
|
<ul class="mt-2 list-disc space-y-1 pl-4 text-xs text-gray-600 dark:text-gray-300">
|
||||||
<li>Uploader: „Custom Upload“ wählen.</li>
|
<li>Uploader starten und Zugangsdaten eintragen.</li>
|
||||||
<li>URL: {{ $result['upload_url'] }}</li>
|
<li>URL: {{ $result['upload_url'] }}</li>
|
||||||
<li>Username/Password eintragen, optional Message.</li>
|
<li>Benutzername/Passwort eintragen.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
|
|
||||||
<div class="mt-6 space-y-4">
|
<div class="mt-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Beispiel (Custom Upload)</p>
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Photobooth Uploader Beispiel</p>
|
||||||
<pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">
|
<pre class="mt-2 rounded-xl border border-gray-200 bg-gray-900 p-4 text-xs text-gray-100 dark:border-white/10">
|
||||||
curl -X POST {{ $result['upload_url'] }} \
|
curl -X POST {{ $result['upload_url'] }} \
|
||||||
-F "media=@your-photo.jpg" \
|
-F "media=@your-photo.jpg" \
|
||||||
@@ -27,9 +27,9 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
|||||||
|
|
||||||
Route::get('/ai-status', [AiStatusController::class, 'checkStatus']);
|
Route::get('/ai-status', [AiStatusController::class, 'checkStatus']);
|
||||||
Route::post('/ai-status/update', [AiStatusController::class, 'checkAndUpdateStatus']);
|
Route::post('/ai-status/update', [AiStatusController::class, 'checkAndUpdateStatus']);
|
||||||
Route::post('/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
|
Route::post('/v1/photobooth/upload', [SparkboothUploadController::class, 'store'])
|
||||||
->middleware('throttle:30,1')
|
->middleware('throttle:30,1')
|
||||||
->name('api.sparkbooth.upload');
|
->name('api.photobooth.upload');
|
||||||
|
|
||||||
Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
|||||||
67
tests/Feature/AdminPinLoginTest.php
Normal file
67
tests/Feature/AdminPinLoginTest.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Auth\Login;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminPinLoginTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_authenticate_with_valid_pin(): void
|
||||||
|
{
|
||||||
|
$user = $this->createAdminUser();
|
||||||
|
$this->assertTrue(Hash::check('1234', $user->admin_pin_hash));
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->call('selectUser', $user->id)
|
||||||
|
->assertSet('data.email', $user->email)
|
||||||
|
->call('appendPinDigit', 1)
|
||||||
|
->call('appendPinDigit', 2)
|
||||||
|
->call('appendPinDigit', 3)
|
||||||
|
->call('appendPinDigit', 4)
|
||||||
|
->assertSet('data.pin', '1234')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasNoErrors()
|
||||||
|
->assertRedirect(Filament::getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_authenticate_with_invalid_pin(): void
|
||||||
|
{
|
||||||
|
$user = $this->createAdminUser();
|
||||||
|
|
||||||
|
Livewire::test(Login::class)
|
||||||
|
->call('selectUser', $user->id)
|
||||||
|
->assertSet('data.email', $user->email)
|
||||||
|
->call('appendPinDigit', 0)
|
||||||
|
->call('appendPinDigit', 0)
|
||||||
|
->call('appendPinDigit', 0)
|
||||||
|
->call('appendPinDigit', 0)
|
||||||
|
->assertSet('data.pin', '0000')
|
||||||
|
->call('authenticate')
|
||||||
|
->assertHasErrors(['data.email']);
|
||||||
|
|
||||||
|
$this->assertGuest();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createAdminUser(): User
|
||||||
|
{
|
||||||
|
$role = Role::firstOrCreate(['name' => 'Admin']);
|
||||||
|
|
||||||
|
return User::factory()->create([
|
||||||
|
'role_id' => $role->id,
|
||||||
|
'admin_pin_hash' => Hash::make('1234'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
tests/Feature/Filament/EditProfilePinTest.php
Normal file
62
tests/Feature/Filament/EditProfilePinTest.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Filament;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Auth\EditProfile;
|
||||||
|
use App\Models\Role;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EditProfilePinTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Filament::setCurrentPanel('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_set_pin_from_profile(): void
|
||||||
|
{
|
||||||
|
$user = $this->createAdminUser();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(EditProfile::class)
|
||||||
|
->set('data.admin_pin', '4321')
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('4321', $user->admin_pin_hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_remove_pin_from_profile(): void
|
||||||
|
{
|
||||||
|
$user = $this->createAdminUser([
|
||||||
|
'admin_pin_hash' => Hash::make('5678'),
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(EditProfile::class)
|
||||||
|
->set('data.remove_admin_pin', true)
|
||||||
|
->call('save')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
$this->assertNull($user->admin_pin_hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createAdminUser(array $overrides = []): User
|
||||||
|
{
|
||||||
|
$role = Role::firstOrCreate(['name' => 'Admin']);
|
||||||
|
|
||||||
|
return User::factory()->create(array_merge([
|
||||||
|
'role_id' => $role->id,
|
||||||
|
], $overrides));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Filament;
|
namespace Tests\Feature\Filament;
|
||||||
|
|
||||||
use App\Filament\Pages\SparkboothConnections;
|
use App\Filament\Pages\PhotoboothConnections;
|
||||||
use App\Models\Gallery;
|
use App\Models\Gallery;
|
||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -11,7 +11,7 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class SparkboothConnectionsTest extends TestCase
|
class PhotoboothConnectionsTest extends TestCase
|
||||||
{
|
{
|
||||||
use DatabaseTransactions;
|
use DatabaseTransactions;
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ class SparkboothConnectionsTest extends TestCase
|
|||||||
$gallery->setUploadToken('tokentest');
|
$gallery->setUploadToken('tokentest');
|
||||||
$gallery->save();
|
$gallery->save();
|
||||||
|
|
||||||
Livewire::test(SparkboothConnections::class)
|
Livewire::test(PhotoboothConnections::class)
|
||||||
->callTableAction('deleteConnection', $gallery);
|
->callTableAction('deleteConnection', $gallery);
|
||||||
|
|
||||||
$updated = $gallery->fresh();
|
$updated = $gallery->fresh();
|
||||||
@@ -27,7 +27,7 @@ class SparkboothUploadTest extends TestCase
|
|||||||
|
|
||||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||||
|
|
||||||
$response = $this->postJson(route('api.sparkbooth.upload'), [
|
$response = $this->postJson(route('api.photobooth.upload'), [
|
||||||
'username' => 'spark-user',
|
'username' => 'spark-user',
|
||||||
'password' => 'secret-123',
|
'password' => 'secret-123',
|
||||||
'media' => $file,
|
'media' => $file,
|
||||||
@@ -42,6 +42,8 @@ class SparkboothUploadTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertJsonStructure(['url']);
|
->assertJsonStructure(['url']);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('/storage/uploads/test/', $response->json('url'));
|
||||||
|
|
||||||
$image = Image::first();
|
$image = Image::first();
|
||||||
$this->assertNotNull($image);
|
$this->assertNotNull($image);
|
||||||
$this->assertSame($gallery->id, $image->gallery_id);
|
$this->assertSame($gallery->id, $image->gallery_id);
|
||||||
@@ -64,7 +66,7 @@ class SparkboothUploadTest extends TestCase
|
|||||||
|
|
||||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||||
|
|
||||||
$response = $this->post(route('api.sparkbooth.upload'), [
|
$response = $this->post(route('api.photobooth.upload'), [
|
||||||
'username' => 'spark-user',
|
'username' => 'spark-user',
|
||||||
'password' => 'wrong',
|
'password' => 'wrong',
|
||||||
'media' => $file,
|
'media' => $file,
|
||||||
@@ -94,7 +96,7 @@ class SparkboothUploadTest extends TestCase
|
|||||||
$binary = base64_encode(file_get_contents($fake->getRealPath()));
|
$binary = base64_encode(file_get_contents($fake->getRealPath()));
|
||||||
$dataUri = 'data:image/png;base64,'.$binary;
|
$dataUri = 'data:image/png;base64,'.$binary;
|
||||||
|
|
||||||
$response = $this->postJson(route('api.sparkbooth.upload'), [
|
$response = $this->postJson(route('api.photobooth.upload'), [
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'media' => $dataUri,
|
'media' => $dataUri,
|
||||||
'filename' => 'custom.png',
|
'filename' => 'custom.png',
|
||||||
@@ -107,6 +109,8 @@ class SparkboothUploadTest extends TestCase
|
|||||||
])
|
])
|
||||||
->assertJsonStructure(['url']);
|
->assertJsonStructure(['url']);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('/storage/uploads/base64/', $response->json('url'));
|
||||||
|
|
||||||
Storage::disk('public')->assertExists('uploads/base64/custom.png');
|
Storage::disk('public')->assertExists('uploads/base64/custom.png');
|
||||||
|
|
||||||
$this->assertDatabaseHas('images', [
|
$this->assertDatabaseHas('images', [
|
||||||
|
|||||||
44
update-prod.bat
Normal file
44
update-prod.bat
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "RUN_NODE=auto"
|
||||||
|
|
||||||
|
call :run "git pull --ff-only" "Git pull"
|
||||||
|
call :run "composer install --no-interaction --no-dev --optimize-autoloader" "Composer install"
|
||||||
|
call :run "php artisan migrate --force" "Migrations"
|
||||||
|
call :run "php artisan config:cache" "Config cache"
|
||||||
|
call :run "php artisan route:cache" "Route cache"
|
||||||
|
call :run "php artisan view:cache" "View cache"
|
||||||
|
call :run "php artisan optimize" "Optimize"
|
||||||
|
|
||||||
|
if /i "%RUN_NODE%"=="1" goto :run_node
|
||||||
|
if /i "%RUN_NODE%"=="auto" goto :maybe_node
|
||||||
|
goto :done
|
||||||
|
|
||||||
|
:maybe_node
|
||||||
|
where npm >nul 2>nul
|
||||||
|
if errorlevel 1 goto :done
|
||||||
|
|
||||||
|
:run_node
|
||||||
|
if exist package-lock.json (
|
||||||
|
call :run "npm ci" "NPM install"
|
||||||
|
) else (
|
||||||
|
call :run "npm install" "NPM install"
|
||||||
|
)
|
||||||
|
call :run "npm run build" "NPM build"
|
||||||
|
goto :done
|
||||||
|
|
||||||
|
:run
|
||||||
|
set "CMD=%~1"
|
||||||
|
set "LABEL=%~2"
|
||||||
|
if "%LABEL%"=="" set "LABEL=%CMD%"
|
||||||
|
echo == %LABEL%
|
||||||
|
%CMD%
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo Command failed: %CMD%
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
exit /b 0
|
||||||
|
|
||||||
|
:done
|
||||||
|
echo Update complete.
|
||||||
@@ -8,7 +8,10 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: 'resources/js/app.js',
|
input: [
|
||||||
|
'resources/js/app.js',
|
||||||
|
'resources/css/filament/admin/theme.css',
|
||||||
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
vue({
|
vue({
|
||||||
|
|||||||
Reference in New Issue
Block a user