- Galerien sind nun eine Entität - es kann mehrere geben
- Neues Sparkbooth-Upload-Feature: Endpoint /api/sparkbooth/upload (Token-basiert pro Galerie), Controller Api/SparkboothUploadController, Migration 2026_01_21_000001_add_upload_fields_to_galleries_table.php mit Upload-Flags/Token/Expiry;
Galerie-Modell und Factory/Seeder entsprechend erweitert.
- Filament: Neue Setup-Seite SparkboothSetup (mit View) zur schnellen Galerie- und Token-Erstellung inkl. QR/Endpoint/Snippet;
Galerie-Link-Views nutzen jetzt simple-qrcode (Composer-Dependency hinzugefügt) und bieten PNG-Download.
- Galerie-Tabelle: Slug/Pfad-Spalten entfernt, Action „Link-Details“ mit Modal; Created-at-Spalte hinzugefügt.
- Zugriffshärtung: Galerie-IDs in API (ImageController, Download/Print) geprüft; GalleryAccess/Middleware + Gallery-Modell/Slug-UUID
eingeführt; GalleryAccess-Inertia-Seite.
- UI/UX: LoadingSpinner/StyledImageDisplay verbessert, Delete-Confirm, Übersetzungen ergänzt.
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,6 +5,8 @@ namespace App\Filament\Pages;
|
|||||||
use App\Services\PrinterService;
|
use App\Services\PrinterService;
|
||||||
use App\Settings\GeneralSettings;
|
use App\Settings\GeneralSettings;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
@@ -14,6 +16,7 @@ use Filament\Notifications\Notification;
|
|||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class GlobalSettings extends Page implements HasForms
|
class GlobalSettings extends Page implements HasForms
|
||||||
@@ -40,7 +43,10 @@ class GlobalSettings extends Page implements HasForms
|
|||||||
|
|
||||||
public function mount(GeneralSettings $settings): void
|
public function mount(GeneralSettings $settings): void
|
||||||
{
|
{
|
||||||
$this->form->fill($settings->toArray());
|
$data = $settings->toArray();
|
||||||
|
$data['gallery_password'] = null;
|
||||||
|
|
||||||
|
$this->form->fill($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
@@ -59,6 +65,24 @@ class GlobalSettings extends Page implements HasForms
|
|||||||
TextInput::make('gallery_heading')
|
TextInput::make('gallery_heading')
|
||||||
->label(__('filament.resource.setting.form.gallery_heading'))
|
->label(__('filament.resource.setting.form.gallery_heading'))
|
||||||
->required(),
|
->required(),
|
||||||
|
Toggle::make('require_gallery_password')
|
||||||
|
->label(__('filament.resource.setting.form.require_gallery_password'))
|
||||||
|
->helperText(__('filament.resource.setting.form.require_gallery_password_help')),
|
||||||
|
TextInput::make('gallery_password')
|
||||||
|
->label(__('filament.resource.setting.form.gallery_password'))
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->helperText(__('filament.resource.setting.form.gallery_password_help'))
|
||||||
|
->dehydrated(true),
|
||||||
|
DateTimePicker::make('gallery_expires_at')
|
||||||
|
->label(__('filament.resource.setting.form.gallery_expires_at'))
|
||||||
|
->native(false)
|
||||||
|
->seconds(false),
|
||||||
|
TextInput::make('gallery_access_duration_minutes')
|
||||||
|
->label(__('filament.resource.setting.form.gallery_access_duration_minutes'))
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->helperText(__('filament.resource.setting.form.gallery_access_duration_help')),
|
||||||
TextInput::make('new_image_timespan_minutes')
|
TextInput::make('new_image_timespan_minutes')
|
||||||
->label(__('filament.resource.setting.form.new_image_timespan_minutes'))
|
->label(__('filament.resource.setting.form.new_image_timespan_minutes'))
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -92,11 +116,39 @@ class GlobalSettings extends Page implements HasForms
|
|||||||
$data['custom_printer_address'] = null;
|
$data['custom_printer_address'] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$data['require_gallery_password'] = (bool) Arr::get($data, 'require_gallery_password', false);
|
||||||
$data['new_image_timespan_minutes'] = (int) Arr::get($data, 'new_image_timespan_minutes', 0);
|
$data['new_image_timespan_minutes'] = (int) Arr::get($data, 'new_image_timespan_minutes', 0);
|
||||||
$data['image_refresh_interval'] = (int) Arr::get($data, 'image_refresh_interval', 0);
|
$data['image_refresh_interval'] = (int) Arr::get($data, 'image_refresh_interval', 0);
|
||||||
$data['max_number_of_copies'] = (int) Arr::get($data, 'max_number_of_copies', 0);
|
$data['max_number_of_copies'] = (int) Arr::get($data, 'max_number_of_copies', 0);
|
||||||
$data['show_print_button'] = (bool) Arr::get($data, 'show_print_button', false);
|
$data['show_print_button'] = (bool) Arr::get($data, 'show_print_button', false);
|
||||||
$data['custom_printer_address'] = $data['custom_printer_address'] ?: null;
|
$data['custom_printer_address'] = $data['custom_printer_address'] ?: null;
|
||||||
|
$duration = Arr::get($data, 'gallery_access_duration_minutes');
|
||||||
|
$data['gallery_access_duration_minutes'] = $duration === null || $duration === '' ? null : (int) $duration;
|
||||||
|
|
||||||
|
$expiresAt = Arr::get($data, 'gallery_expires_at');
|
||||||
|
$data['gallery_expires_at'] = $expiresAt ? Carbon::parse($expiresAt) : null;
|
||||||
|
|
||||||
|
$newPassword = Arr::get($data, 'gallery_password');
|
||||||
|
$currentHash = $settings->gallery_password_hash;
|
||||||
|
|
||||||
|
if (! $data['require_gallery_password']) {
|
||||||
|
$data['gallery_password_hash'] = null;
|
||||||
|
} else {
|
||||||
|
$data['gallery_password_hash'] = $newPassword
|
||||||
|
? Hash::make($newPassword)
|
||||||
|
: $currentHash;
|
||||||
|
|
||||||
|
if (! $data['gallery_password_hash']) {
|
||||||
|
Notification::make()
|
||||||
|
->title(__('filament.resource.setting.form.gallery_password_missing'))
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($data['gallery_password']);
|
||||||
|
|
||||||
$settings->fill($data)->save();
|
$settings->fill($data)->save();
|
||||||
|
|
||||||
|
|||||||
107
app/Filament/Pages/SparkboothSetup.php
Normal file
107
app/Filament/Pages/SparkboothSetup.php
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class SparkboothSetup extends Page implements HasForms
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-camera';
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'Admin';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.sparkbooth-setup';
|
||||||
|
|
||||||
|
public ?array $data = [];
|
||||||
|
|
||||||
|
public ?array $result = null;
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'Sparkbooth Setup';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Event-Name')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Galerie Titel')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('images_path')
|
||||||
|
->label('Upload-Pfad')
|
||||||
|
->helperText('Relativ zu public/storage, z.B. uploads/event-xyz')
|
||||||
|
->default(fn () => 'uploads/'.Str::slug('event-'.Str::random(4)))
|
||||||
|
->required(),
|
||||||
|
Toggle::make('allow_print')
|
||||||
|
->label('Drucken erlauben')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('allow_ai_styles')
|
||||||
|
->label('AI-Stile erlauben')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('upload_enabled')
|
||||||
|
->label('Uploads aktivieren')
|
||||||
|
->default(true),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
$data = $this->form->getState();
|
||||||
|
|
||||||
|
$gallery = new Gallery([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'title' => $data['title'],
|
||||||
|
'images_path' => trim($data['images_path'], '/'),
|
||||||
|
'is_public' => true,
|
||||||
|
'allow_ai_styles' => (bool) $data['allow_ai_styles'],
|
||||||
|
'allow_print' => (bool) $data['allow_print'],
|
||||||
|
'upload_enabled' => (bool) $data['upload_enabled'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gallery->slug = Str::uuid()->toString();
|
||||||
|
|
||||||
|
$plainToken = Str::random(40);
|
||||||
|
$gallery->setUploadToken($plainToken);
|
||||||
|
$gallery->save();
|
||||||
|
|
||||||
|
$this->result = [
|
||||||
|
'gallery' => $gallery->only(['id', 'name', 'slug', 'images_path']),
|
||||||
|
'upload_token' => $plainToken,
|
||||||
|
'upload_url' => route('api.sparkbooth.upload'),
|
||||||
|
'gallery_url' => route('gallery.show', $gallery),
|
||||||
|
];
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Galerie erstellt und Upload-Token generiert.')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFormActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
\Filament\Actions\Action::make('save')
|
||||||
|
->label('Setup erstellen')
|
||||||
|
->submit('save'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Filament/Resources/Galleries/GalleryResource.php
Normal file
86
app/Filament/Resources/Galleries/GalleryResource.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Galleries\Pages\CreateGallery;
|
||||||
|
use App\Filament\Resources\Galleries\Pages\EditGallery;
|
||||||
|
use App\Filament\Resources\Galleries\Pages\ListGalleries;
|
||||||
|
use App\Filament\Resources\Galleries\Schemas\GalleryForm;
|
||||||
|
use App\Filament\Resources\Galleries\Tables\GalleriesTable;
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
class GalleryResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Gallery::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||||
|
|
||||||
|
protected static UnitEnum|string|null $navigationGroup = 'Content';
|
||||||
|
|
||||||
|
public static function getNavigationGroup(): ?string
|
||||||
|
{
|
||||||
|
return __('filament.navigation.groups.content');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return GalleryForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return GalleriesTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListGalleries::route('/'),
|
||||||
|
'create' => CreateGallery::route('/create'),
|
||||||
|
'edit' => EditGallery::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mutateFormDataBeforeCreate(array $data): array
|
||||||
|
{
|
||||||
|
$data = self::mutatePassword($data);
|
||||||
|
$data['slug'] = $data['slug'] ?: Str::uuid()->toString();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function mutateFormDataBeforeSave(array $data): array
|
||||||
|
{
|
||||||
|
$data = self::mutatePassword($data);
|
||||||
|
$data['slug'] = $data['slug'] ?: Str::uuid()->toString();
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function mutatePassword(array $data): array
|
||||||
|
{
|
||||||
|
$password = $data['password'] ?? null;
|
||||||
|
unset($data['password']);
|
||||||
|
|
||||||
|
if (! empty($password)) {
|
||||||
|
$data['password_hash'] = Hash::make($password);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Galleries/Pages/CreateGallery.php
Normal file
11
app/Filament/Resources/Galleries/Pages/CreateGallery.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Galleries\GalleryResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateGallery extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GalleryResource::class;
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Galleries/Pages/EditGallery.php
Normal file
19
app/Filament/Resources/Galleries/Pages/EditGallery.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Galleries\GalleryResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditGallery extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = GalleryResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Galleries/Pages/ListGalleries.php
Normal file
19
app/Filament/Resources/Galleries/Pages/ListGalleries.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Galleries\GalleryResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListGalleries extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = GalleryResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Filament/Resources/Galleries/Schemas/GalleryForm.php
Normal file
74
app/Filament/Resources/Galleries/Schemas/GalleryForm.php
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\View;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
||||||
|
class GalleryForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Details')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->label('Name')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('slug')
|
||||||
|
->label('Slug')
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(true)
|
||||||
|
->helperText('Wird automatisch erzeugt'),
|
||||||
|
TextInput::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->required(),
|
||||||
|
TextInput::make('images_path')
|
||||||
|
->label('Bilder-Pfad')
|
||||||
|
->helperText('Relativer Pfad unter public/storage')
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_public')
|
||||||
|
->label('Öffentlich')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('allow_ai_styles')
|
||||||
|
->label('AI-Stile erlauben')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('allow_print')
|
||||||
|
->label('Drucken erlauben')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('require_password')
|
||||||
|
->label('Passwortschutz aktiv')
|
||||||
|
->default(false),
|
||||||
|
TextInput::make('password')
|
||||||
|
->label('Neues Passwort')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->dehydrated(false)
|
||||||
|
->helperText('Leer lassen, um das bestehende Passwort zu behalten.'),
|
||||||
|
DateTimePicker::make('expires_at')
|
||||||
|
->label('Ablaufdatum')
|
||||||
|
->native(false)
|
||||||
|
->seconds(false),
|
||||||
|
TextInput::make('access_duration_minutes')
|
||||||
|
->label('Zugriffsdauer (Minuten)')
|
||||||
|
->numeric()
|
||||||
|
->minValue(1)
|
||||||
|
->nullable()
|
||||||
|
->helperText('Optional: Zeitfenster nach dem ersten Unlock.'),
|
||||||
|
]),
|
||||||
|
View::make('filament.components.gallery-link')
|
||||||
|
->columnSpanFull()
|
||||||
|
->visible(fn (?object $record) => (bool) $record?->id)
|
||||||
|
->viewData(fn (?object $record) => [
|
||||||
|
'url' => $record ? URL::route('gallery.show', $record) : null,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Filament/Resources/Galleries/Tables/GalleriesTable.php
Normal file
63
app/Filament/Resources/Galleries/Tables/GalleriesTable.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Galleries\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class GalleriesTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('title')
|
||||||
|
->label('Titel')
|
||||||
|
->sortable()
|
||||||
|
->searchable(),
|
||||||
|
IconColumn::make('allow_ai_styles')
|
||||||
|
->label('AI-Stile')
|
||||||
|
->boolean(),
|
||||||
|
IconColumn::make('allow_print')
|
||||||
|
->label('Drucken')
|
||||||
|
->boolean(),
|
||||||
|
IconColumn::make('require_password')
|
||||||
|
->label('Passwort')
|
||||||
|
->boolean(),
|
||||||
|
TextColumn::make('expires_at')
|
||||||
|
->label('Läuft ab')
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label('Erstellt am')
|
||||||
|
->dateTime('d.m.Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
//
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
Action::make('link_details')
|
||||||
|
->label('Link-Details')
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->modalHeading('Link-Details')
|
||||||
|
->modalContent(fn ($record) => view('filament.components.gallery-link-modal', [
|
||||||
|
'record' => $record,
|
||||||
|
'url' => route('gallery.show', $record),
|
||||||
|
]))
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelAction(false),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Api\Plugins\PluginLoader;
|
use App\Api\Plugins\PluginLoader;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ApiProvider;
|
use App\Models\ApiProvider;
|
||||||
|
use App\Models\Gallery;
|
||||||
use App\Models\Image;
|
use App\Models\Image;
|
||||||
use App\Models\Style;
|
use App\Models\Style;
|
||||||
use App\Settings\GeneralSettings;
|
use App\Settings\GeneralSettings;
|
||||||
@@ -19,35 +20,48 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$publicUploadsPath = public_path('storage/uploads');
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
|
if (! $gallery) {
|
||||||
|
return response()->json(['error' => 'Gallery not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$imagesPath = trim($gallery->images_path, '/');
|
||||||
|
$publicUploadsPath = public_path('storage/'.$imagesPath);
|
||||||
|
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
if (! File::exists($publicUploadsPath)) {
|
if (! File::exists($publicUploadsPath)) {
|
||||||
File::makeDirectory($publicUploadsPath, 0755, true);
|
File::makeDirectory($publicUploadsPath, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get files from the public/storage/uploads directory
|
// Get files from the gallery-specific directory
|
||||||
$diskFiles = File::files($publicUploadsPath);
|
$diskFiles = File::files($publicUploadsPath);
|
||||||
$diskImagePaths = [];
|
$diskImagePaths = [];
|
||||||
foreach ($diskFiles as $file) {
|
foreach ($diskFiles as $file) {
|
||||||
// Store path relative to public/storage/
|
// Store path relative to public/storage/
|
||||||
$diskImagePaths[] = 'uploads/'.$file->getFilename();
|
$diskImagePaths[] = $imagesPath.'/'.$file->getFilename();
|
||||||
}
|
}
|
||||||
|
|
||||||
$dbImagePaths = Image::pluck('path')->toArray();
|
$dbImagePaths = Image::where('gallery_id', $gallery->id)->pluck('path')->toArray();
|
||||||
|
|
||||||
// Add images from disk that are not in the database
|
// Add images from disk that are not in the database
|
||||||
$imagesToAdd = array_diff($diskImagePaths, $dbImagePaths);
|
$imagesToAdd = array_diff($diskImagePaths, $dbImagePaths);
|
||||||
foreach ($imagesToAdd as $path) {
|
foreach ($imagesToAdd as $path) {
|
||||||
Image::create(['path' => $path, 'is_public' => true]);
|
Image::create([
|
||||||
|
'gallery_id' => $gallery->id,
|
||||||
|
'path' => $path,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove images from database that are not on disk
|
// Remove images from database that are not on disk
|
||||||
$imagesToRemove = array_diff($dbImagePaths, $diskImagePaths);
|
$imagesToRemove = array_diff($dbImagePaths, $diskImagePaths);
|
||||||
Image::whereIn('path', $imagesToRemove)->delete();
|
Image::where('gallery_id', $gallery->id)
|
||||||
|
->whereIn('path', $imagesToRemove)
|
||||||
|
->delete();
|
||||||
|
|
||||||
// Fetch images from the database after synchronization
|
// Fetch images from the database after synchronization
|
||||||
$query = Image::orderBy('updated_at', 'desc');
|
$query = Image::where('gallery_id', $gallery->id)->orderBy('updated_at', 'desc');
|
||||||
|
|
||||||
// If user is not authenticated, filter by is_public, but also include their temporary images
|
// If user is not authenticated, filter by is_public, but also include their temporary images
|
||||||
if (! auth()->check()) {
|
if (! auth()->check()) {
|
||||||
@@ -85,11 +99,15 @@ class ImageController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image' => 'required|image|mimes:jpeg,png,bmp,gif,webp|max:10240', // Max 10MB
|
'image' => 'required|image|mimes:jpeg,png,bmp,gif,webp|max:10240', // Max 10MB
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$gallery = Gallery::where('slug', $request->string('gallery'))->firstOrFail();
|
||||||
|
$imagesPath = trim($gallery->images_path, '/');
|
||||||
|
|
||||||
$file = $request->file('image');
|
$file = $request->file('image');
|
||||||
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
$fileName = uniqid().'.'.$file->getClientOriginalExtension();
|
||||||
$destinationPath = public_path('storage/uploads');
|
$destinationPath = public_path('storage/'.$imagesPath);
|
||||||
|
|
||||||
// Ensure the directory exists
|
// Ensure the directory exists
|
||||||
if (! File::exists($destinationPath)) {
|
if (! File::exists($destinationPath)) {
|
||||||
@@ -97,9 +115,10 @@ class ImageController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$file->move($destinationPath, $fileName);
|
$file->move($destinationPath, $fileName);
|
||||||
$relativePath = 'uploads/'.$fileName; // Path relative to public/storage/
|
$relativePath = $imagesPath.'/'.$fileName; // Path relative to public/storage/
|
||||||
|
|
||||||
$image = Image::create([
|
$image = Image::create([
|
||||||
|
'gallery_id' => $gallery->id,
|
||||||
'path' => $relativePath,
|
'path' => $relativePath,
|
||||||
'is_public' => true,
|
'is_public' => true,
|
||||||
]);
|
]);
|
||||||
@@ -136,9 +155,19 @@ class ImageController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'image_id' => 'required|exists:images,id',
|
'image_id' => 'required|exists:images,id',
|
||||||
'style_id' => 'nullable|exists:styles,id',
|
'style_id' => 'nullable|exists:styles,id',
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$image = Image::find($request->image_id);
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
|
$image = Image::query()
|
||||||
|
->where('id', $request->integer('image_id'))
|
||||||
|
->where('gallery_id', optional($gallery)->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $image) {
|
||||||
|
return response()->json(['error' => __('api.image_not_found')], 404);
|
||||||
|
}
|
||||||
$style = null;
|
$style = null;
|
||||||
|
|
||||||
if ($request->style_id) {
|
if ($request->style_id) {
|
||||||
@@ -217,9 +246,13 @@ class ImageController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image_id' => 'required|exists:images,id',
|
'image_id' => 'required|exists:images,id',
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$image = Image::find($request->image_id);
|
$gallery = $this->resolveGallery($request);
|
||||||
|
$image = Image::where('id', $request->integer('image_id'))
|
||||||
|
->where('gallery_id', optional($gallery)->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
if (! $image) {
|
if (! $image) {
|
||||||
return response()->json(['error' => __('api.image_not_found')], 404);
|
return response()->json(['error' => __('api.image_not_found')], 404);
|
||||||
@@ -233,6 +266,16 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
public function deleteImage(Image $image)
|
public function deleteImage(Image $image)
|
||||||
{
|
{
|
||||||
|
request()->validate([
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gallery = $this->resolveGallery(request());
|
||||||
|
|
||||||
|
if ($gallery && (int) $image->gallery_id !== (int) $gallery->id) {
|
||||||
|
return response()->json(['error' => __('api.image_not_found')], 404);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete from the public/storage directory
|
// Delete from the public/storage directory
|
||||||
File::delete(public_path('storage/'.$image->path));
|
File::delete(public_path('storage/'.$image->path));
|
||||||
@@ -244,14 +287,46 @@ class ImageController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteStyled(Request $request, Image $image)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
|
if ($gallery && (int) $image->gallery_id !== (int) $gallery->id) {
|
||||||
|
return response()->json(['error' => __('api.image_not_found')], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $image->is_temp) {
|
||||||
|
return response()->json(['error' => __('api.image_not_found')], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
File::delete(public_path('storage/'.$image->path));
|
||||||
|
$image->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => __('api.image_deleted_successfully')]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json(['error' => $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function getStatus(Request $request)
|
public function getStatus(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image_id' => 'required|exists:images,id',
|
'image_id' => 'required|exists:images,id',
|
||||||
'api_provider_name' => 'required|string',
|
'api_provider_name' => 'required|string',
|
||||||
|
'gallery' => 'sometimes|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$image = Image::find($request->image_id);
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
|
$image = Image::query()
|
||||||
|
->where('id', $request->integer('image_id'))
|
||||||
|
->when($gallery, fn ($q) => $q->where('gallery_id', $gallery->id))
|
||||||
|
->first();
|
||||||
$apiProvider = ApiProvider::where('name', $request->api_provider_name)->first();
|
$apiProvider = ApiProvider::where('name', $request->api_provider_name)->first();
|
||||||
|
|
||||||
if (! $image || ! $apiProvider) {
|
if (! $image || ! $apiProvider) {
|
||||||
@@ -268,14 +343,26 @@ class ImageController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fetchStyledImage(string $promptId)
|
public function fetchStyledImage(Request $request, string $promptId)
|
||||||
{
|
{
|
||||||
|
$request->validate([
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
Log::info('fetchStyledImage called.', ['prompt_id' => $promptId]);
|
Log::info('fetchStyledImage called.', ['prompt_id' => $promptId]);
|
||||||
try {
|
try {
|
||||||
// Find the image associated with the prompt_id, eagerly loading relationships
|
// Find the image associated with the prompt_id, eagerly loading relationships
|
||||||
$image = Image::with(['style.aiModel' => function ($query) {
|
$imageQuery = Image::with(['style.aiModel' => function ($query) {
|
||||||
$query->with('primaryApiProvider');
|
$query->with('primaryApiProvider');
|
||||||
}])->where('comfyui_prompt_id', $promptId)->first();
|
}])->where('comfyui_prompt_id', $promptId);
|
||||||
|
|
||||||
|
if ($gallery) {
|
||||||
|
$imageQuery->where('gallery_id', $gallery->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = $imageQuery->first();
|
||||||
|
|
||||||
if (! $image) {
|
if (! $image) {
|
||||||
Log::warning('fetchStyledImage: Image not found for prompt_id.', ['prompt_id' => $promptId]);
|
Log::warning('fetchStyledImage: Image not found for prompt_id.', ['prompt_id' => $promptId]);
|
||||||
@@ -325,18 +412,20 @@ class ImageController extends Controller
|
|||||||
$decodedImage = base64_decode(preg_replace('#^data:image/\w+;base64, #i', '', $base64Image));
|
$decodedImage = base64_decode(preg_replace('#^data:image/\w+;base64, #i', '', $base64Image));
|
||||||
|
|
||||||
$newImageName = 'styled_'.uniqid().'.png';
|
$newImageName = 'styled_'.uniqid().'.png';
|
||||||
$newImagePathRelative = 'uploads/'.$newImageName;
|
$galleryPath = trim($image->gallery?->images_path ?: 'uploads', '/');
|
||||||
|
$newImagePathRelative = $galleryPath.'/'.$newImageName;
|
||||||
$newImageFullPath = public_path('storage/'.$newImagePathRelative);
|
$newImageFullPath = public_path('storage/'.$newImagePathRelative);
|
||||||
|
|
||||||
if (! File::exists(public_path('storage/uploads'))) {
|
if (! File::exists(public_path('storage/'.$galleryPath))) {
|
||||||
File::makeDirectory(public_path('storage/uploads'), 0755, true);
|
File::makeDirectory(public_path('storage/'.$galleryPath), 0755, true);
|
||||||
Log::info('Created uploads directory.', ['path' => public_path('storage/uploads')]);
|
Log::info('Created uploads directory.', ['path' => public_path('storage/'.$galleryPath)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
File::put($newImageFullPath, $decodedImage); // Save using File facade
|
File::put($newImageFullPath, $decodedImage); // Save using File facade
|
||||||
Log::info('Image saved to disk.', ['path' => $newImageFullPath]);
|
Log::info('Image saved to disk.', ['path' => $newImageFullPath]);
|
||||||
|
|
||||||
$newImage = Image::create([
|
$newImage = Image::create([
|
||||||
|
'gallery_id' => $image->gallery_id,
|
||||||
'path' => $newImagePathRelative, // Store relative path
|
'path' => $newImagePathRelative, // Store relative path
|
||||||
'original_image_id' => $image->id,
|
'original_image_id' => $image->id,
|
||||||
'style_id' => $style->id,
|
'style_id' => $style->id,
|
||||||
@@ -416,4 +505,25 @@ class ImageController extends Controller
|
|||||||
|
|
||||||
return response()->json(['comfyui_url' => rtrim($apiProvider->api_url, '/')]);
|
return response()->json(['comfyui_url' => rtrim($apiProvider->api_url, '/')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveGallery(Request $request): ?Gallery
|
||||||
|
{
|
||||||
|
$routeGallery = $request->route('gallery');
|
||||||
|
|
||||||
|
if ($routeGallery instanceof Gallery) {
|
||||||
|
return $routeGallery;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $routeGallery;
|
||||||
|
|
||||||
|
if (! $slug) {
|
||||||
|
$slug = $request->query('gallery');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $slug) {
|
||||||
|
return Gallery::first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gallery::where('slug', $slug)->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
87
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
87
app/Http/Controllers/Api/SparkboothUploadController.php
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use App\Models\Image;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class SparkboothUploadController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'token' => ['required', 'string'],
|
||||||
|
'file' => ['required', 'file', 'mimes:jpeg,png,gif,bmp,webp', 'max:10240'],
|
||||||
|
'filename' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$gallery = $this->resolveGalleryByToken($request->string('token'));
|
||||||
|
|
||||||
|
if (! $gallery) {
|
||||||
|
return response()->json(['error' => 'Invalid token.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $gallery->upload_enabled) {
|
||||||
|
return response()->json(['error' => 'Uploads are disabled for this gallery.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($gallery->upload_token_expires_at && now()->greaterThanOrEqualTo($gallery->upload_token_expires_at)) {
|
||||||
|
return response()->json(['error' => 'Upload token expired.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$safeName = $this->buildFilename($file->getClientOriginalExtension(), $request->input('filename'));
|
||||||
|
$relativePath = trim($gallery->images_path, '/').'/'.$safeName;
|
||||||
|
$destinationPath = public_path('storage/'.dirname($relativePath));
|
||||||
|
|
||||||
|
if (! File::exists($destinationPath)) {
|
||||||
|
File::makeDirectory($destinationPath, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$file->move($destinationPath, basename($relativePath));
|
||||||
|
|
||||||
|
$image = Image::create([
|
||||||
|
'gallery_id' => $gallery->id,
|
||||||
|
'path' => $relativePath,
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Upload ok',
|
||||||
|
'image_id' => $image->id,
|
||||||
|
'url' => asset('storage/'.$relativePath),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGalleryByToken(string $token): ?Gallery
|
||||||
|
{
|
||||||
|
$galleries = Gallery::query()
|
||||||
|
->whereNotNull('upload_token_hash')
|
||||||
|
->where('upload_enabled', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($galleries as $gallery) {
|
||||||
|
if ($gallery->upload_token_hash && Hash::check($token, $gallery->upload_token_hash)) {
|
||||||
|
return $gallery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFilename(string $extension, ?string $preferred = null): string
|
||||||
|
{
|
||||||
|
$extension = strtolower($extension ?: 'jpg');
|
||||||
|
$base = $preferred
|
||||||
|
? Str::slug(pathinfo($preferred, PATHINFO_FILENAME))
|
||||||
|
: 'sparkbooth_'.now()->format('Ymd_His').'_'.Str::random(6);
|
||||||
|
|
||||||
|
return $base.'.'.$extension;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,16 @@ class DownloadController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'image_path' => 'required|string',
|
'image_path' => 'required|string',
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$gallery = \App\Models\Gallery::where('slug', $request->string('gallery'))->firstOrFail();
|
||||||
|
|
||||||
$resolvedPath = $this->resolveImagePath($request->input('image_path'));
|
$resolvedPath = $this->resolveImagePath($request->input('image_path'));
|
||||||
|
|
||||||
if (! $resolvedPath || ! File::exists($resolvedPath)) {
|
$expectedFragment = DIRECTORY_SEPARATOR.trim($gallery->images_path, '/').DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
|
if (! $resolvedPath || ! File::exists($resolvedPath) || ! str_contains($resolvedPath, $expectedFragment)) {
|
||||||
Log::error("DownloadController: Image file not found at {$resolvedPath}");
|
Log::error("DownloadController: Image file not found at {$resolvedPath}");
|
||||||
|
|
||||||
return response()->json(['error' => 'Image file not found.'], 404);
|
return response()->json(['error' => 'Image file not found.'], 404);
|
||||||
|
|||||||
110
app/Http/Controllers/GalleryAccessController.php
Normal file
110
app/Http/Controllers/GalleryAccessController.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureGalleryAccess;
|
||||||
|
use App\Http\Requests\GalleryAccessRequest;
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use App\Settings\GeneralSettings;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class GalleryAccessController extends Controller
|
||||||
|
{
|
||||||
|
public function create(Request $request, GeneralSettings $settings, ?Gallery $gallery = null): Response
|
||||||
|
{
|
||||||
|
$gallery = $this->resolveGallery($request, $gallery);
|
||||||
|
|
||||||
|
if (! $gallery) {
|
||||||
|
abort(404, 'Gallery not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$expiresAt = $gallery->expires_at ?? $settings->gallery_expires_at;
|
||||||
|
|
||||||
|
$expired = $expiresAt !== null
|
||||||
|
&& Carbon::now()->greaterThanOrEqualTo(Carbon::parse($expiresAt));
|
||||||
|
|
||||||
|
return Inertia::render('GalleryAccess', [
|
||||||
|
'gallery' => [
|
||||||
|
'id' => $gallery->id,
|
||||||
|
'slug' => $gallery->slug,
|
||||||
|
'title' => $gallery->title,
|
||||||
|
],
|
||||||
|
'requiresPassword' => (bool) ($gallery->require_password ?? $settings->require_gallery_password),
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
'accessDurationMinutes' => $gallery->access_duration_minutes ?? $settings->gallery_access_duration_minutes,
|
||||||
|
'expired' => $expired,
|
||||||
|
'flashMessage' => $request->session()->get('gallery_access_message'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(GalleryAccessRequest $request, GeneralSettings $settings, ?Gallery $gallery = null): RedirectResponse
|
||||||
|
{
|
||||||
|
$gallery = $this->resolveGallery($request, $gallery);
|
||||||
|
|
||||||
|
if (! $gallery) {
|
||||||
|
abort(404, 'Gallery not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isExpired($gallery, $settings)) {
|
||||||
|
return redirect()
|
||||||
|
->route('gallery.access.show', $gallery)
|
||||||
|
->with('gallery_access_message', __('api.gallery.expired'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiresPassword = $gallery->require_password ?? $settings->require_gallery_password;
|
||||||
|
$passwordHash = $gallery->password_hash ?? $settings->gallery_password_hash;
|
||||||
|
|
||||||
|
if (! $requiresPassword || ! $passwordHash) {
|
||||||
|
EnsureGalleryAccess::grantForGallery($request, $gallery, $settings);
|
||||||
|
|
||||||
|
return redirect()->route('gallery.show', $gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Hash::check($request->input('password'), $passwordHash)) {
|
||||||
|
return redirect()
|
||||||
|
->route('gallery.access.show', $gallery)
|
||||||
|
->with('gallery_access_message', __('api.gallery.invalid_password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
EnsureGalleryAccess::grantForGallery($request, $gallery, $settings);
|
||||||
|
|
||||||
|
return redirect()->route('gallery.show', $gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGallery(Request $request, ?Gallery $gallery = null): ?Gallery
|
||||||
|
{
|
||||||
|
if ($gallery instanceof Gallery) {
|
||||||
|
return $gallery;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $request->route('gallery');
|
||||||
|
|
||||||
|
if (! $slug) {
|
||||||
|
return Gallery::first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($slug instanceof Gallery) {
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gallery::where('slug', $slug)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExpired(?Gallery $gallery, GeneralSettings $settings): bool
|
||||||
|
{
|
||||||
|
$expiresAt = $gallery?->expires_at ?? $settings->gallery_expires_at;
|
||||||
|
|
||||||
|
if ($expiresAt) {
|
||||||
|
$expiresAt = Carbon::parse($expiresAt);
|
||||||
|
|
||||||
|
return Carbon::now()->greaterThanOrEqualTo($expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,29 +2,48 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
use App\Models\Image;
|
use App\Models\Image;
|
||||||
use App\Settings\GeneralSettings;
|
use App\Settings\GeneralSettings;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
class HomeController extends Controller
|
class HomeController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private GeneralSettings $settings) {}
|
public function __construct(private GeneralSettings $settings) {}
|
||||||
|
|
||||||
public function index()
|
public function index(Request $request, ?Gallery $gallery = null)
|
||||||
{
|
{
|
||||||
$galleryHeading = $this->settings->gallery_heading;
|
$gallery = $gallery ?: Gallery::first();
|
||||||
|
|
||||||
|
if (! $gallery) {
|
||||||
|
abort(404, 'Gallery not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$galleryHeading = $gallery->title ?: $this->settings->gallery_heading;
|
||||||
$newImageTimespanMinutes = $this->settings->new_image_timespan_minutes;
|
$newImageTimespanMinutes = $this->settings->new_image_timespan_minutes;
|
||||||
|
|
||||||
$images = Image::all()->map(function ($image) use ($newImageTimespanMinutes) {
|
$images = Image::where('gallery_id', $gallery->id)
|
||||||
$image->is_new = Carbon::parse($image->created_at)->diffInMinutes(Carbon::now()) <= $newImageTimespanMinutes;
|
->orderByDesc('updated_at')
|
||||||
$image->path = 'storage/'.$image->path;
|
->get()
|
||||||
|
->map(function ($image) use ($newImageTimespanMinutes) {
|
||||||
|
$image->is_new = Carbon::parse($image->created_at)->diffInMinutes(Carbon::now()) <= $newImageTimespanMinutes;
|
||||||
|
$image->path = asset('storage/'.$image->path);
|
||||||
|
|
||||||
return $image;
|
return $image;
|
||||||
});
|
});
|
||||||
|
|
||||||
return Inertia::render('Home', [
|
return Inertia::render('Home', [
|
||||||
'galleryHeading' => $galleryHeading,
|
'galleryHeading' => $galleryHeading,
|
||||||
|
'gallery' => [
|
||||||
|
'id' => $gallery->id,
|
||||||
|
'slug' => $gallery->slug,
|
||||||
|
'title' => $galleryHeading,
|
||||||
|
'allow_ai_styles' => $gallery->allow_ai_styles,
|
||||||
|
'allow_print' => $gallery->allow_print,
|
||||||
|
'images_path' => $gallery->images_path,
|
||||||
|
],
|
||||||
'images' => $images,
|
'images' => $images,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
use App\Services\PrinterService;
|
use App\Services\PrinterService;
|
||||||
use App\Settings\GeneralSettings;
|
use App\Settings\GeneralSettings;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -16,9 +17,25 @@ class PrintController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'image_path' => 'required|string',
|
'image_path' => 'required|string',
|
||||||
'quantity' => 'required|integer|min:1',
|
'quantity' => 'required|integer|min:1',
|
||||||
|
'gallery' => 'required|string|exists:galleries,slug',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$gallery = Gallery::where('slug', $request->string('gallery'))->firstOrFail();
|
||||||
|
|
||||||
|
if ($gallery && ! $gallery->allow_print) {
|
||||||
|
return response()->json(['error' => 'Printing is disabled for this gallery.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
$imagePath = public_path(str_replace(url('/'), '', $request->input('image_path')));
|
$imagePath = public_path(str_replace(url('/'), '', $request->input('image_path')));
|
||||||
|
|
||||||
|
if (! str_contains($imagePath, DIRECTORY_SEPARATOR.trim($gallery->images_path, '/').DIRECTORY_SEPARATOR)) {
|
||||||
|
Log::warning('PrintController: Image path does not belong to gallery.', [
|
||||||
|
'gallery' => $gallery->slug,
|
||||||
|
'image_path' => $imagePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['error' => 'Image file not found.'], 404);
|
||||||
|
}
|
||||||
$quantity = $request->input('quantity');
|
$quantity = $request->input('quantity');
|
||||||
|
|
||||||
$printerName = $this->settings->selected_printer === '__custom__'
|
$printerName = $this->settings->selected_printer === '__custom__'
|
||||||
|
|||||||
288
app/Http/Middleware/EnsureGalleryAccess.php
Normal file
288
app/Http/Middleware/EnsureGalleryAccess.php
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use App\Settings\GeneralSettings;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureGalleryAccess
|
||||||
|
{
|
||||||
|
public const SESSION_GRANTED_BASE = 'gallery_access_granted';
|
||||||
|
|
||||||
|
public const SESSION_GRANTED_AT_BASE = 'gallery_access_granted_at';
|
||||||
|
|
||||||
|
public const COOKIE_GRANTED_AT_BASE = 'gallery_access_granted_at';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$settings = app(GeneralSettings::class);
|
||||||
|
$gallery = $this->resolveGallery($request);
|
||||||
|
|
||||||
|
if ($this->requestedGalleryMissing($request, $gallery)) {
|
||||||
|
return $this->missingGalleryResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isGalleryExpired($gallery, $settings)) {
|
||||||
|
return $this->deny($request, __('api.gallery.expired'), $gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasValidAccess = $this->hasValidAccess($request, $gallery, $settings);
|
||||||
|
$hasExistingGrant = (bool) ($this->sessionGrantedAt($request, $gallery) ?? $this->cookieGrantedAt($request, $gallery));
|
||||||
|
|
||||||
|
if ($hasValidAccess) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->accessDuration($gallery, $settings) && $hasExistingGrant) {
|
||||||
|
return $this->deny($request, __('api.gallery.expired'), $gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->requiresPassword($gallery, $settings) || ! $this->passwordHash($gallery, $settings)) {
|
||||||
|
if ($this->accessDuration($gallery, $settings) && ! $hasExistingGrant) {
|
||||||
|
self::grantForGallery($request, $gallery, $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->deny($request, __('api.gallery.password_required'), $gallery);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function grantForGallery(Request $request, ?Gallery $gallery, GeneralSettings $settings, ?Carbon $grantedAt = null): void
|
||||||
|
{
|
||||||
|
$grantedAt = $grantedAt ?: Carbon::now();
|
||||||
|
$suffix = self::keySuffix($gallery);
|
||||||
|
|
||||||
|
if ($request->hasSession()) {
|
||||||
|
$request->session()->put(self::SESSION_GRANTED_BASE.'_'.$suffix, true);
|
||||||
|
$request->session()->put(self::SESSION_GRANTED_AT_BASE.'_'.$suffix, $grantedAt->toIso8601String());
|
||||||
|
}
|
||||||
|
|
||||||
|
$cookieMinutes = self::calculateCookieLifetime($gallery, $settings, $grantedAt);
|
||||||
|
|
||||||
|
cookie()->queue(
|
||||||
|
cookie(
|
||||||
|
self::COOKIE_GRANTED_AT_BASE.'_'.$suffix,
|
||||||
|
$grantedAt->toIso8601String(),
|
||||||
|
$cookieMinutes,
|
||||||
|
path: '/',
|
||||||
|
secure: config('session.secure', false),
|
||||||
|
httpOnly: true,
|
||||||
|
raw: false,
|
||||||
|
sameSite: config('session.same_site', 'lax')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasValidAccess(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool
|
||||||
|
{
|
||||||
|
if ($this->sessionAccessValid($request, $gallery, $settings)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grantedAt = $this->cookieGrantedAt($request, $gallery);
|
||||||
|
|
||||||
|
if (! $grantedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $this->accessDuration($gallery, $settings);
|
||||||
|
|
||||||
|
if ($duration) {
|
||||||
|
return ! $grantedAt->copy()->addMinutes($duration)->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) $grantedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sessionAccessValid(Request $request, ?Gallery $gallery, GeneralSettings $settings): bool
|
||||||
|
{
|
||||||
|
if (! $request->hasSession()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$suffix = self::keySuffix($gallery);
|
||||||
|
|
||||||
|
if (! $request->session()->get(self::SESSION_GRANTED_BASE.'_'.$suffix, false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$grantedAt = $this->sessionGrantedAt($request, $gallery);
|
||||||
|
|
||||||
|
if (! $grantedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $this->accessDuration($gallery, $settings);
|
||||||
|
|
||||||
|
if ($duration) {
|
||||||
|
$expiresAt = $grantedAt->copy()->addMinutes($duration);
|
||||||
|
|
||||||
|
if ($expiresAt->isPast()) {
|
||||||
|
$request->session()->forget([
|
||||||
|
self::SESSION_GRANTED_BASE.'_'.$suffix,
|
||||||
|
self::SESSION_GRANTED_AT_BASE.'_'.$suffix,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sessionGrantedAt(Request $request, ?Gallery $gallery): ?Carbon
|
||||||
|
{
|
||||||
|
if (! $request->hasSession()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = $request->session()->get(self::SESSION_GRANTED_AT_BASE.'_'.self::keySuffix($gallery));
|
||||||
|
|
||||||
|
if (! $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cookieGrantedAt(Request $request, ?Gallery $gallery): ?Carbon
|
||||||
|
{
|
||||||
|
$value = $request->cookie(self::COOKIE_GRANTED_AT_BASE.'_'.self::keySuffix($gallery));
|
||||||
|
|
||||||
|
if (! $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$value = Crypt::decryptString($value);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// Cookie might already be decrypted by web middleware.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Carbon::parse($value);
|
||||||
|
} catch (\Exception) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isGalleryExpired(?Gallery $gallery, GeneralSettings $settings): bool
|
||||||
|
{
|
||||||
|
$expiresAt = $this->expiresAt($gallery, $settings);
|
||||||
|
|
||||||
|
return $expiresAt !== null && Carbon::now()->greaterThanOrEqualTo($expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deny(Request $request, string $message, ?Gallery $gallery = null): Response
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => $message], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeName = $gallery ? 'gallery.access.show' : 'gallery.access.default';
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route($routeName, $gallery)
|
||||||
|
->with('gallery_access_message', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function calculateCookieLifetime(?Gallery $gallery, GeneralSettings $settings, Carbon $grantedAt): int
|
||||||
|
{
|
||||||
|
$expiresAt = $gallery?->expires_at ?? $settings->gallery_expires_at;
|
||||||
|
|
||||||
|
if ($expiresAt) {
|
||||||
|
$expiresAt = Carbon::parse($expiresAt);
|
||||||
|
|
||||||
|
return max(1, $grantedAt->diffInMinutes($expiresAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
$duration = $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes;
|
||||||
|
|
||||||
|
if ($duration) {
|
||||||
|
return max(1, $duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 24 * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGallery(Request $request): ?Gallery
|
||||||
|
{
|
||||||
|
$routeGallery = $request->route('gallery');
|
||||||
|
|
||||||
|
if ($routeGallery instanceof Gallery) {
|
||||||
|
return $routeGallery;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = is_string($routeGallery) ? $routeGallery : $request->query('gallery');
|
||||||
|
|
||||||
|
if (! $slug) {
|
||||||
|
return Gallery::first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gallery::where('slug', $slug)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestedGalleryMissing(Request $request, ?Gallery $gallery): bool
|
||||||
|
{
|
||||||
|
if ($gallery) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) ($request->route('gallery') || $request->query('gallery'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function missingGalleryResponse(Request $request): Response
|
||||||
|
{
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['message' => 'Gallery not found.'], Response::HTTP_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(404, 'Gallery not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function keySuffix(?Gallery $gallery): string
|
||||||
|
{
|
||||||
|
return $gallery ? 'gallery_'.$gallery->id : 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expiresAt(?Gallery $gallery, GeneralSettings $settings): ?Carbon
|
||||||
|
{
|
||||||
|
$value = $gallery?->expires_at ?? $settings->gallery_expires_at;
|
||||||
|
|
||||||
|
if (! $value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Carbon::parse($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function accessDuration(?Gallery $gallery, GeneralSettings $settings): ?int
|
||||||
|
{
|
||||||
|
return $gallery?->access_duration_minutes ?? $settings->gallery_access_duration_minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresPassword(?Gallery $gallery, GeneralSettings $settings): bool
|
||||||
|
{
|
||||||
|
return (bool) ($gallery?->require_password ?? $settings->require_gallery_password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function passwordHash(?Gallery $gallery, GeneralSettings $settings): ?string
|
||||||
|
{
|
||||||
|
return $gallery?->password_hash ?? $settings->gallery_password_hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,9 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
],
|
],
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'settings' => app(GeneralSettings::class)->toArray(),
|
'settings' => tap(app(GeneralSettings::class)->toArray(), function (&$settings) {
|
||||||
|
unset($settings['gallery_password_hash']);
|
||||||
|
}),
|
||||||
'translations' => function () use ($request) {
|
'translations' => function () use ($request) {
|
||||||
$currentLocale = app()->getLocale(); // Store current locale
|
$currentLocale = app()->getLocale(); // Store current locale
|
||||||
$requestedLocale = $request->input('locale', $currentLocale);
|
$requestedLocale = $request->input('locale', $currentLocale);
|
||||||
|
|||||||
28
app/Http/Requests/GalleryAccessRequest.php
Normal file
28
app/Http/Requests/GalleryAccessRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class GalleryAccessRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => ['required', 'string', 'min:4'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Models/Gallery.php
Normal file
63
app/Models/Gallery.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Gallery extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\GalleryFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'title',
|
||||||
|
'images_path',
|
||||||
|
'is_public',
|
||||||
|
'allow_ai_styles',
|
||||||
|
'allow_print',
|
||||||
|
'require_password',
|
||||||
|
'password_hash',
|
||||||
|
'expires_at',
|
||||||
|
'access_duration_minutes',
|
||||||
|
'upload_enabled',
|
||||||
|
'upload_token_hash',
|
||||||
|
'upload_token_expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_public' => 'bool',
|
||||||
|
'allow_ai_styles' => 'bool',
|
||||||
|
'allow_print' => 'bool',
|
||||||
|
'require_password' => 'bool',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'access_duration_minutes' => 'int',
|
||||||
|
'upload_enabled' => 'bool',
|
||||||
|
'upload_token_expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function images(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Image::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUploadToken(string $token): void
|
||||||
|
{
|
||||||
|
$this->upload_token_hash = \Illuminate\Support\Facades\Hash::make($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateUploadToken(): string
|
||||||
|
{
|
||||||
|
$token = \Illuminate\Support\Str::random(40);
|
||||||
|
$this->setUploadToken($token);
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,14 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class Image extends Model
|
class Image extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'gallery_id',
|
||||||
'path',
|
'path',
|
||||||
'uuid',
|
'uuid',
|
||||||
'original_image_id',
|
'original_image_id',
|
||||||
@@ -20,8 +21,21 @@ class Image extends Model
|
|||||||
'comfyui_prompt_id',
|
'comfyui_prompt_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_temp' => 'bool',
|
||||||
|
'is_public' => 'bool',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function style()
|
public function style()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Style::class);
|
return $this->belongsTo(Style::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function gallery(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Gallery::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Settings;
|
namespace App\Settings;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
use Spatie\LaravelSettings\Settings;
|
use Spatie\LaravelSettings\Settings;
|
||||||
|
|
||||||
class GeneralSettings extends Settings
|
class GeneralSettings extends Settings
|
||||||
{
|
{
|
||||||
public string $gallery_heading = 'Style Gallery';
|
public string $gallery_heading = 'Style Gallery';
|
||||||
|
|
||||||
|
public bool $require_gallery_password = false;
|
||||||
|
|
||||||
|
public ?string $gallery_password_hash = null;
|
||||||
|
|
||||||
|
public ?DateTimeInterface $gallery_expires_at = null;
|
||||||
|
|
||||||
|
public ?int $gallery_access_duration_minutes = null;
|
||||||
|
|
||||||
public int $new_image_timespan_minutes = 60;
|
public int $new_image_timespan_minutes = 60;
|
||||||
|
|
||||||
public int $image_refresh_interval = 30_000;
|
public int $image_refresh_interval = 30_000;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$middleware->api(prepend: [
|
||||||
|
\App\Http\Middleware\EncryptCookies::class,
|
||||||
|
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
|
||||||
|
\Illuminate\Session\Middleware\StartSession::class,
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'auth' => \App\Http\Middleware\Authenticate::class,
|
'auth' => \App\Http\Middleware\Authenticate::class,
|
||||||
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
|
||||||
@@ -28,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
'signed' => \App\Http\Middleware\ValidateSignature::class,
|
||||||
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
|
||||||
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
|
||||||
|
'gallery.access' => \App\Http\Middleware\EnsureGalleryAccess::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^2.8",
|
"laravel/tinker": "^2.8",
|
||||||
"predis/predis": "^3.1",
|
"predis/predis": "^3.1",
|
||||||
|
"simplesoftwareio/simple-qrcode": "*",
|
||||||
"spatie/laravel-settings": "*",
|
"spatie/laravel-settings": "*",
|
||||||
"spatie/simple-excel": "^3.8",
|
"spatie/simple-excel": "^3.8",
|
||||||
"tightenco/ziggy": "^2.0"
|
"tightenco/ziggy": "^2.0"
|
||||||
|
|||||||
174
composer.lock
generated
174
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1a407b8d311c235ec776b18ee56e1223",
|
"content-hash": "3edef01c74be9931db5658ff80564372",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "amphp/amp",
|
"name": "amphp/amp",
|
||||||
@@ -1197,6 +1197,60 @@
|
|||||||
},
|
},
|
||||||
"time": "2025-07-30T15:45:57+00:00"
|
"time": "2025-07-30T15:45:57+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "bacon/bacon-qr-code",
|
||||||
|
"version": "2.0.8",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/Bacon/BaconQrCode.git",
|
||||||
|
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||||
|
"reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"dasprid/enum": "^1.0.3",
|
||||||
|
"ext-iconv": "*",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phly/keep-a-changelog": "^2.1",
|
||||||
|
"phpunit/phpunit": "^7 | ^8 | ^9",
|
||||||
|
"spatie/phpunit-snapshot-assertions": "^4.2.9",
|
||||||
|
"squizlabs/php_codesniffer": "^3.4"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-imagick": "to generate QR code images"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"BaconQrCode\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "BaconQrCode is a QR code generator for PHP.",
|
||||||
|
"homepage": "https://github.com/Bacon/BaconQrCode",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/Bacon/BaconQrCode/issues",
|
||||||
|
"source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
|
||||||
|
},
|
||||||
|
"time": "2022-12-07T17:46:57+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "blade-ui-kit/blade-heroicons",
|
"name": "blade-ui-kit/blade-heroicons",
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
@@ -1740,6 +1794,56 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-02-21T08:52:11+00:00"
|
"time": "2025-02-21T08:52:11+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "dasprid/enum",
|
||||||
|
"version": "1.0.7",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/DASPRiD/Enum.git",
|
||||||
|
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||||
|
"reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.1 <9.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
|
||||||
|
"squizlabs/php_codesniffer": "*"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"DASPRiD\\Enum\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"BSD-2-Clause"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Ben Scholzen 'DASPRiD'",
|
||||||
|
"email": "mail@dasprids.de",
|
||||||
|
"homepage": "https://dasprids.de/",
|
||||||
|
"role": "Developer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP 7.1 enum implementation",
|
||||||
|
"keywords": [
|
||||||
|
"enum",
|
||||||
|
"map"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/DASPRiD/Enum/issues",
|
||||||
|
"source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
|
||||||
|
},
|
||||||
|
"time": "2025-09-16T12:23:56+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "daverandom/libdns",
|
"name": "daverandom/libdns",
|
||||||
"version": "v2.1.0",
|
"version": "v2.1.0",
|
||||||
@@ -6896,6 +7000,74 @@
|
|||||||
],
|
],
|
||||||
"time": "2022-12-17T21:53:22+00:00"
|
"time": "2022-12-17T21:53:22+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "simplesoftwareio/simple-qrcode",
|
||||||
|
"version": "4.2.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SimpleSoftwareIO/simple-qrcode.git",
|
||||||
|
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SimpleSoftwareIO/simple-qrcode/zipball/916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||||
|
"reference": "916db7948ca6772d54bb617259c768c9cdc8d537",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"bacon/bacon-qr-code": "^2.0",
|
||||||
|
"ext-gd": "*",
|
||||||
|
"php": ">=7.2|^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"mockery/mockery": "~1",
|
||||||
|
"phpunit/phpunit": "~9"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-imagick": "Allows the generation of PNG QrCodes.",
|
||||||
|
"illuminate/support": "Allows for use within Laravel."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"aliases": {
|
||||||
|
"QrCode": "SimpleSoftwareIO\\QrCode\\Facades\\QrCode"
|
||||||
|
},
|
||||||
|
"providers": [
|
||||||
|
"SimpleSoftwareIO\\QrCode\\QrCodeServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"SimpleSoftwareIO\\QrCode\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Simple Software LLC",
|
||||||
|
"email": "support@simplesoftware.io"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Simple QrCode is a QR code generator made for Laravel.",
|
||||||
|
"homepage": "https://www.simplesoftware.io/#/docs/simple-qrcode",
|
||||||
|
"keywords": [
|
||||||
|
"Simple",
|
||||||
|
"generator",
|
||||||
|
"laravel",
|
||||||
|
"qrcode",
|
||||||
|
"wrapper"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/SimpleSoftwareIO/simple-qrcode/issues",
|
||||||
|
"source": "https://github.com/SimpleSoftwareIO/simple-qrcode/tree/4.2.0"
|
||||||
|
},
|
||||||
|
"time": "2021-02-08T20:43:55+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "spatie/invade",
|
"name": "spatie/invade",
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
|
|||||||
40
database/factories/GalleryFactory.php
Normal file
40
database/factories/GalleryFactory.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Gallery>
|
||||||
|
*/
|
||||||
|
class GalleryFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$name = $this->faker->words(2, true);
|
||||||
|
$slug = Str::uuid()->toString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'slug' => $slug,
|
||||||
|
'title' => $name,
|
||||||
|
'images_path' => 'uploads/'.$slug,
|
||||||
|
'is_public' => true,
|
||||||
|
'allow_ai_styles' => true,
|
||||||
|
'allow_print' => true,
|
||||||
|
'require_password' => false,
|
||||||
|
'password_hash' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'access_duration_minutes' => null,
|
||||||
|
'upload_enabled' => false,
|
||||||
|
'upload_token_hash' => null,
|
||||||
|
'upload_token_expires_at' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,6 +110,10 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'gallery_heading' => 'Style Gallery',
|
'gallery_heading' => 'Style Gallery',
|
||||||
|
'require_gallery_password' => false,
|
||||||
|
'gallery_password_hash' => null,
|
||||||
|
'gallery_expires_at' => null,
|
||||||
|
'gallery_access_duration_minutes' => null,
|
||||||
'new_image_timespan_minutes' => 60,
|
'new_image_timespan_minutes' => 60,
|
||||||
'image_refresh_interval' => 30_000,
|
'image_refresh_interval' => 30_000,
|
||||||
'max_number_of_copies' => 3,
|
'max_number_of_copies' => 3,
|
||||||
@@ -124,9 +128,10 @@ return new class extends Migration
|
|||||||
'image_refresh_interval',
|
'image_refresh_interval',
|
||||||
'max_number_of_copies',
|
'max_number_of_copies',
|
||||||
'default_style_id',
|
'default_style_id',
|
||||||
|
'gallery_access_duration_minutes',
|
||||||
];
|
];
|
||||||
|
|
||||||
$boolKeys = ['show_print_button'];
|
$boolKeys = ['show_print_button', 'require_gallery_password'];
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('galleries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('images_path')->default('uploads');
|
||||||
|
$table->boolean('is_public')->default(true);
|
||||||
|
$table->boolean('allow_ai_styles')->default(true);
|
||||||
|
$table->boolean('allow_print')->default(true);
|
||||||
|
$table->boolean('require_password')->default(false);
|
||||||
|
$table->string('password_hash')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->unsignedInteger('access_duration_minutes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
$defaultGalleryId = DB::table('galleries')->insertGetId([
|
||||||
|
'name' => 'Default Gallery',
|
||||||
|
'slug' => Str::uuid()->toString(),
|
||||||
|
'title' => 'Style Gallery',
|
||||||
|
'images_path' => 'uploads',
|
||||||
|
'is_public' => true,
|
||||||
|
'allow_ai_styles' => true,
|
||||||
|
'allow_print' => true,
|
||||||
|
'require_password' => false,
|
||||||
|
'password_hash' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'access_duration_minutes' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('images', function (Blueprint $table) use ($defaultGalleryId): void {
|
||||||
|
$table->foreignId('gallery_id')
|
||||||
|
->after('id')
|
||||||
|
->default($defaultGalleryId)
|
||||||
|
->constrained();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('images', function (Blueprint $table): void {
|
||||||
|
if (Schema::hasColumn('images', 'gallery_id')) {
|
||||||
|
$table->dropConstrainedForeignId('gallery_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('galleries');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?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('galleries', function (Blueprint $table): void {
|
||||||
|
$table->boolean('upload_enabled')->default(false)->after('access_duration_minutes');
|
||||||
|
$table->string('upload_token_hash')->nullable()->after('upload_enabled');
|
||||||
|
$table->timestamp('upload_token_expires_at')->nullable()->after('upload_token_hash');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('galleries', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'upload_enabled',
|
||||||
|
'upload_token_hash',
|
||||||
|
'upload_token_expires_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
AiModelSeeder::class,
|
AiModelSeeder::class,
|
||||||
AiModelApiProviderSeeder::class,
|
AiModelApiProviderSeeder::class,
|
||||||
SettingSeeder::class,
|
SettingSeeder::class,
|
||||||
|
GallerySeeder::class,
|
||||||
StyleSeeder::class,
|
StyleSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
39
database/seeders/GallerySeeder.php
Normal file
39
database/seeders/GallerySeeder.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use App\Settings\GeneralSettings;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class GallerySeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
/** @var \App\Settings\GeneralSettings $settings */
|
||||||
|
$settings = app(GeneralSettings::class);
|
||||||
|
|
||||||
|
Gallery::firstOrCreate(
|
||||||
|
['slug' => Str::uuid()->toString()],
|
||||||
|
[
|
||||||
|
'name' => 'Default Gallery',
|
||||||
|
'title' => $settings->gallery_heading ?? 'Style Gallery',
|
||||||
|
'images_path' => 'uploads',
|
||||||
|
'is_public' => true,
|
||||||
|
'allow_ai_styles' => true,
|
||||||
|
'allow_print' => true,
|
||||||
|
'require_password' => (bool) $settings->require_gallery_password,
|
||||||
|
'password_hash' => $settings->gallery_password_hash,
|
||||||
|
'expires_at' => $settings->gallery_expires_at,
|
||||||
|
'access_duration_minutes' => $settings->gallery_access_duration_minutes,
|
||||||
|
'upload_enabled' => false,
|
||||||
|
'upload_token_hash' => null,
|
||||||
|
'upload_token_expires_at' => null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ class SettingSeeder extends Seeder
|
|||||||
'image_refresh_interval' => 30_000,
|
'image_refresh_interval' => 30_000,
|
||||||
'max_number_of_copies' => 3,
|
'max_number_of_copies' => 3,
|
||||||
'show_print_button' => true,
|
'show_print_button' => true,
|
||||||
|
'require_gallery_password' => false,
|
||||||
|
'gallery_password_hash' => null,
|
||||||
|
'gallery_expires_at' => null,
|
||||||
|
'gallery_access_duration_minutes' => null,
|
||||||
'selected_printer' => null,
|
'selected_printer' => null,
|
||||||
'custom_printer_address' => null,
|
'custom_printer_address' => null,
|
||||||
'default_style_id' => null,
|
'default_style_id' => null,
|
||||||
|
|||||||
@@ -47,17 +47,17 @@
|
|||||||
<button
|
<button
|
||||||
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="[aiAvailable ? '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']"
|
||||||
:disabled="!aiAvailable"
|
:disabled="!effectiveAiAvailable"
|
||||||
@click="showStyleSelectorView = true"
|
@click="showStyleSelectorView = true"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="h-2 w-2 rounded-full"
|
<span class="h-2 w-2 rounded-full"
|
||||||
:class="aiAvailable ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
:class="effectiveAiAvailable ? 'bg-emerald-500' : 'bg-rose-500'"></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base">Stile anzeigen</p>
|
<p class="text-base">Stile anzeigen</p>
|
||||||
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">
|
<p class="text-sm font-normal text-slate-500 dark:text-slate-400">
|
||||||
{{ aiAvailable ? 'Lass die KI dein Motiv verzaubern' : 'AI-Dienste derzeit nicht verfügbar' }}
|
{{ effectiveAiAvailable ? 'Lass die KI dein Motiv verzaubern' : 'AI-Dienste derzeit nicht verfügbar' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -126,6 +126,9 @@
|
|||||||
<StyleSelector
|
<StyleSelector
|
||||||
class="max-h-[520px] flex-1"
|
class="max-h-[520px] flex-1"
|
||||||
:image_id="image?.id ?? image?.image_id"
|
:image_id="image?.id ?? image?.image_id"
|
||||||
|
:allow-ai-styles="props.allowAiStyles"
|
||||||
|
:gallery-slug="props.gallerySlug"
|
||||||
|
:ai-available-override="props.aiAvailable"
|
||||||
@styleSelected="handleStyleSelected"
|
@styleSelected="handleStyleSelected"
|
||||||
@close="$emit('close')"
|
@close="$emit('close')"
|
||||||
/>
|
/>
|
||||||
@@ -149,12 +152,31 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
allowAiStyles: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
showPrintButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
gallerySlug: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
},
|
||||||
|
aiAvailable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['close', 'print', 'styleSelected', 'download']);
|
const emits = defineEmits(['close', 'print', 'styleSelected', 'download']);
|
||||||
|
|
||||||
const settings = computed(() => page.props.settings || {});
|
const settings = computed(() => page.props.settings || {});
|
||||||
const showPrintButton = computed(() => settings.value.show_print_button ?? true);
|
const showPrintButton = computed(() => {
|
||||||
|
const fromSettings = settings.value.show_print_button ?? true;
|
||||||
|
return fromSettings && props.showPrintButton;
|
||||||
|
});
|
||||||
|
|
||||||
const shouldShowDownload = computed(() => {
|
const shouldShowDownload = computed(() => {
|
||||||
const hostname = window.location.hostname;
|
const hostname = window.location.hostname;
|
||||||
@@ -166,23 +188,9 @@ const shouldShowDownload = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const showStyleSelectorView = ref(false);
|
const showStyleSelectorView = ref(false);
|
||||||
const aiAvailable = ref(false);
|
const effectiveAiAvailable = computed(() => props.allowAiStyles && props.aiAvailable);
|
||||||
|
|
||||||
const checkAiStatus = async () => {
|
onMounted(() => {});
|
||||||
try {
|
|
||||||
const response = await axios.get('/api/ai-status');
|
|
||||||
aiAvailable.value = response.data.some(provider => provider.available);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking AI status:', error);
|
|
||||||
aiAvailable.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkAiStatus();
|
|
||||||
// Check every 5 minutes
|
|
||||||
setInterval(checkAiStatus, 300000);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.image,
|
() => props.image,
|
||||||
|
|||||||
@@ -1,38 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
<div class="fixed inset-0 z-[200] flex items-center justify-center bg-slate-900/80 backdrop-blur">
|
||||||
<div class="bg-white p-6 rounded-lg shadow-lg text-center flex flex-col items-center">
|
<div class="relative flex w-full max-w-md flex-col items-center gap-4 rounded-3xl border border-white/10 bg-white/95 p-8 text-center shadow-2xl dark:bg-slate-900/95">
|
||||||
<div class="loader ease-linear rounded-full border-4 border-t-4 border-gray-200 h-12 w-12 mb-4"></div>
|
<div class="relative">
|
||||||
<p class="text-gray-700 text-lg">{{ __('api.loading_spinner.processing_image') }}</p>
|
<div class="h-16 w-16 rounded-full border-4 border-white/40 border-t-emerald-400 animate-spin"></div>
|
||||||
<p v-if="progress > 0" class="text-gray-700 text-sm mt-2">{{ progress }}%</p>
|
<div class="absolute inset-0 flex items-center justify-center text-sm font-semibold text-emerald-600 dark:text-emerald-300">
|
||||||
|
{{ progress ? `${progress}%` : '...' }}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<p class="text-lg font-semibold text-slate-900 dark:text-white">{{ __('api.loading_spinner.processing_image') }}</p>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-300">{{ __('api.loading_spinner.processing_wait') }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps } from 'vue';
|
defineProps({
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
progress: {
|
progress: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.loader {
|
|
||||||
border-top-color: #3498db; /* Blue color for the spinner */
|
|
||||||
-webkit-animation: spinner 1.5s linear infinite;
|
|
||||||
animation: spinner 1.5s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@-webkit-keyframes spinner {
|
|
||||||
0% { -webkit-transform: rotate(0deg); }
|
|
||||||
100% { -webkit-transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ const props = defineProps({
|
|||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
allowAiStyles: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
gallerySlug: {
|
||||||
|
type: String,
|
||||||
|
default: 'default',
|
||||||
|
},
|
||||||
|
aiAvailableOverride: {
|
||||||
|
type: Boolean,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['styleSelected', 'close']);
|
const emits = defineEmits(['styleSelected', 'close']);
|
||||||
@@ -75,22 +87,39 @@ const fetchStyles = async () => {
|
|||||||
loadError.value = null;
|
loadError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check AI availability first
|
if (!props.allowAiStyles) {
|
||||||
const aiStatusResponse = await axios.get('/api/ai-status');
|
aiAvailable.value = false;
|
||||||
const aiStatus = aiStatusResponse.data;
|
loadError.value = 'Stilwechsel ist für diese Galerie deaktiviert.';
|
||||||
aiAvailable.value = aiStatus.some(provider => provider.available);
|
|
||||||
|
|
||||||
if (!aiAvailable.value) {
|
|
||||||
loadError.value = 'AI-Dienste sind derzeit nicht verfügbar. Bitte versuchen Sie es später erneut.';
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.aiAvailableOverride !== null) {
|
||||||
|
aiAvailable.value = props.aiAvailableOverride;
|
||||||
|
} else {
|
||||||
|
aiAvailable.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch styles only if AI is available
|
// Fetch styles only if AI is available
|
||||||
const stylesResponse = await axios.get('/api/styles');
|
const stylesResponse = await axios.get('/api/styles', {
|
||||||
styles.value = stylesResponse.data.filter(style => {
|
params: { gallery: props.gallerySlug },
|
||||||
// Only show styles from available providers
|
});
|
||||||
return style.ai_model && style.ai_model.api_provider && style.ai_model.api_provider.enabled;
|
|
||||||
|
const payload = Array.isArray(stylesResponse.data) ? stylesResponse.data : [];
|
||||||
|
|
||||||
|
styles.value = payload.filter((style) => {
|
||||||
|
if (! props.allowAiStyles) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aiModel = style.ai_model || style.aiModel || {};
|
||||||
|
const provider = aiModel.primary_api_provider || aiModel.api_provider || {};
|
||||||
|
|
||||||
|
if (provider.enabled === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return aiModel && (provider || aiModel);
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -105,7 +134,17 @@ const selectStyle = (style) => {
|
|||||||
emits('styleSelected', style, props.image_id);
|
emits('styleSelected', style, props.image_id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildPreviewPath = (preview) => `/storage/${preview}`;
|
const buildPreviewPath = (preview) => {
|
||||||
|
if (! preview) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.startsWith('http://') || preview.startsWith('https://') || preview.startsWith('/')) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/storage/${preview}`;
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchStyles();
|
fetchStyles();
|
||||||
|
|||||||
@@ -1,41 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
<div class="fixed inset-0 z-[210] flex items-center justify-center bg-slate-900/80 backdrop-blur">
|
||||||
<div class="bg-white p-4 rounded-lg shadow-lg max-w-3xl w-full text-center">
|
<div class="w-full max-w-4xl overflow-hidden rounded-3xl border border-white/10 bg-white/95 shadow-2xl ring-1 ring-black/10 dark:bg-slate-900/95">
|
||||||
<h2 class="text-xl font-bold mb-4">{{ __('api.styled_image_display.title') }}</h2>
|
<div class="flex flex-col gap-6 p-6 sm:p-8">
|
||||||
<img :src="image.path" alt="Styled Image" class="max-w-full h-auto mx-auto mb-4 rounded-md" />
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex justify-center space-x-4">
|
<div>
|
||||||
<button
|
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">Ergebnis</p>
|
||||||
@click="$emit('keep', image)"
|
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ __('api.styled_image_display.title') }}</h2>
|
||||||
class="px-6 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 focus:outline-hidden focus:ring-2 focus:ring-green-500 focus:ring-opacity-50"
|
<p class="text-sm text-slate-500 dark:text-slate-300">{{ __('api.styled_image_display.keep_hint') }}</p>
|
||||||
>
|
</div>
|
||||||
{{ __('api.styled_image_display.keep_button') }}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
<button
|
class="rounded-full border border-white/20 bg-white/10 p-2 text-slate-900 shadow-sm transition hover:border-slate-300 hover:text-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-slate-300 dark:text-white"
|
||||||
@click="$emit('delete', image)"
|
@click="$emit('close')"
|
||||||
class="px-6 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 focus:outline-hidden focus:ring-2 focus:ring-red-500 focus:ring-opacity-50"
|
aria-label="Schließen"
|
||||||
>
|
>
|
||||||
{{ __('api.styled_image_display.delete_button') }}
|
<font-awesome-icon :icon="['fas', 'xmark']" class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded-2xl border border-slate-200/60 bg-slate-50 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<img :src="image.path" alt="Styled Image" class="mx-auto max-h-[60vh] w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-slate-200 bg-white px-5 py-2.5 text-sm font-semibold text-slate-800 shadow-sm transition hover:border-emerald-300 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300 dark:border-white/20 dark:bg-white/10 dark:text-white"
|
||||||
|
@click="$emit('keep', image)"
|
||||||
|
>
|
||||||
|
{{ __('api.styled_image_display.keep_button') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!confirmingDelete"
|
||||||
|
type="button"
|
||||||
|
class="rounded-2xl border border-rose-200 bg-rose-50 px-5 py-2.5 text-sm font-semibold text-rose-700 shadow-sm transition hover:border-rose-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-300 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100"
|
||||||
|
@click="confirmingDelete = true"
|
||||||
|
>
|
||||||
|
{{ __('api.styled_image_display.delete_button') }}
|
||||||
|
</button>
|
||||||
|
<div v-else class="flex items-center gap-2 rounded-2xl border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 shadow-sm dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||||
|
<span>{{ __('api.styled_image_display.delete_confirm') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-rose-600 px-3 py-1 text-white shadow-sm transition hover:bg-rose-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80"
|
||||||
|
@click="$emit('delete', image)"
|
||||||
|
>
|
||||||
|
{{ __('api.styled_image_display.delete_button') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-1 text-slate-700 transition hover:border-slate-300 dark:border-white/20 dark:text-white"
|
||||||
|
@click="confirmingDelete = false"
|
||||||
|
>
|
||||||
|
{{ __('settings.cancel_button') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
const props = defineProps({
|
defineProps({
|
||||||
image: {
|
image: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(['keep', 'delete']);
|
defineEmits(['keep', 'delete', 'close']);
|
||||||
|
|
||||||
console.log('StyledImageDisplay: image prop:', props.image);
|
const confirmingDelete = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Add any specific styles for the modal here if needed */
|
|
||||||
</style>
|
|
||||||
|
|||||||
137
resources/js/Pages/GalleryAccess.vue
Normal file
137
resources/js/Pages/GalleryAccess.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<Head :title="__('api.gallery.access_title')" />
|
||||||
|
<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="flex min-h-screen items-center justify-center px-4 py-12">
|
||||||
|
<div class="w-full max-w-xl space-y-8 rounded-3xl border border-slate-200/70 bg-white/80 p-8 shadow-2xl backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xs uppercase tracking-[0.35em] text-slate-500 dark:text-slate-400">
|
||||||
|
Style Gallery
|
||||||
|
</p>
|
||||||
|
<h1 class="text-3xl font-semibold text-slate-900 dark:text-white">
|
||||||
|
{{ __('api.gallery.access_title') }}
|
||||||
|
</h1>
|
||||||
|
<p v-if="expired" class="text-sm text-rose-600 dark:text-rose-300">
|
||||||
|
{{ __('api.gallery.expired') }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{{ props.flashMessage || __('api.gallery.password_required') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-300">
|
||||||
|
<span v-if="formattedExpiresAt" class="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 dark:bg-white/10">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-amber-400"></span>
|
||||||
|
{{ __('api.gallery.expires_at_hint', { datetime: formattedExpiresAt }) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="props.accessDurationMinutes" class="inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 dark:bg-white/10">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-emerald-400"></span>
|
||||||
|
{{ __('api.gallery.duration_hint', { minutes: props.accessDurationMinutes }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form v-if="requiresPassword && !expired" @submit.prevent="submit" class="space-y-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label for="password" class="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{{ __('api.gallery.password_label') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
v-model="form.password"
|
||||||
|
type="password"
|
||||||
|
class="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-base text-slate-900 shadow-sm transition focus:border-emerald-400 focus:outline-none focus:ring-2 focus:ring-emerald-400/40 dark:border-white/10 dark:bg-white/5 dark:text-white"
|
||||||
|
:aria-invalid="Boolean(form.errors.password)"
|
||||||
|
/>
|
||||||
|
<p v-if="form.errors.password" class="text-sm text-rose-500">
|
||||||
|
{{ form.errors.password }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center justify-center rounded-2xl bg-emerald-500 px-4 py-3 text-base font-semibold text-white shadow-lg shadow-emerald-500/30 transition hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400 focus:ring-offset-2 focus:ring-offset-white disabled:opacity-60 dark:focus:ring-offset-slate-900"
|
||||||
|
:disabled="form.processing"
|
||||||
|
>
|
||||||
|
{{ form.processing ? '...' : __('api.gallery.submit_password') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else-if="!expired" class="rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-100">
|
||||||
|
{{ __('api.gallery.password_required') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expired" class="space-y-3 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-100">
|
||||||
|
<p>{{ __('api.gallery.expired') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Head, useForm } from '@inertiajs/vue3';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
requiresPassword: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
gallery: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
expiresAt: {
|
||||||
|
type: [String, Date, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
accessDurationMinutes: {
|
||||||
|
type: [Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
expired: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
flashMessage: {
|
||||||
|
type: String,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiresPassword = computed(() => Boolean(props.requiresPassword));
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedExpiresAt = computed(() => {
|
||||||
|
if (!props.expiresAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(props.expiresAt);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const hasGallery = Boolean(props.gallery?.slug);
|
||||||
|
const routeName = hasGallery ? 'gallery.access.store' : 'gallery.access.default.store';
|
||||||
|
const params = hasGallery ? { gallery: props.gallery.slug } : {};
|
||||||
|
|
||||||
|
form.post(route(routeName, params), {
|
||||||
|
preserveScroll: true,
|
||||||
|
onError: () => {
|
||||||
|
form.password = '';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
<span>{{ currentTheme === 'light' ? __('api.dark_mode') : __('api.light_mode') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="!aiAvailable"
|
v-if="!effectiveAiAvailable"
|
||||||
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="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"
|
||||||
>
|
>
|
||||||
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
<span class="h-2 w-2 rounded-full bg-rose-500"></span>
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
<ImageContextMenu
|
<ImageContextMenu
|
||||||
v-if="currentOverlayComponent === 'contextMenu' && selectedImage"
|
v-if="currentOverlayComponent === 'contextMenu' && selectedImage"
|
||||||
:image="selectedImage"
|
:image="selectedImage"
|
||||||
|
:allow-ai-styles="allowAiStyles"
|
||||||
|
:show-print-button="showPrintButton"
|
||||||
|
:gallery-slug="gallerySlug"
|
||||||
|
:ai-available="effectiveAiAvailable"
|
||||||
@close="closeOverlays"
|
@close="closeOverlays"
|
||||||
@print="printImage"
|
@print="printImage"
|
||||||
@download="downloadImage"
|
@download="downloadImage"
|
||||||
@@ -103,7 +107,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, usePage } from '@inertiajs/vue3';
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import GalleryGrid from '../Components/GalleryGrid.vue';
|
import GalleryGrid from '../Components/GalleryGrid.vue';
|
||||||
@@ -118,12 +122,22 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: 'Gallery',
|
default: 'Gallery',
|
||||||
},
|
},
|
||||||
|
gallery: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
images: {
|
images: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const page = usePage();
|
||||||
|
const settings = computed(() => page.props.settings || {});
|
||||||
|
const gallerySlug = computed(() => props.gallery?.slug || 'default');
|
||||||
|
const allowAiStyles = computed(() => props.gallery?.allow_ai_styles !== false);
|
||||||
|
const allowPrint = computed(() => props.gallery?.allow_print !== false);
|
||||||
|
|
||||||
const images = ref([]);
|
const images = ref([]);
|
||||||
const imagesPerPage = 12;
|
const imagesPerPage = 12;
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
@@ -194,6 +208,10 @@ const paginatedImages = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const refreshIntervalSeconds = computed(() => Math.max(1, Math.round(refreshIntervalMs.value / 1000)));
|
const refreshIntervalSeconds = computed(() => Math.max(1, Math.round(refreshIntervalMs.value / 1000)));
|
||||||
|
const showPrintButton = computed(() => {
|
||||||
|
return (settings.value.show_print_button ?? true) && allowPrint.value;
|
||||||
|
});
|
||||||
|
const effectiveAiAvailable = computed(() => allowAiStyles.value && aiAvailable.value);
|
||||||
const formattedLastRefresh = computed(() =>
|
const formattedLastRefresh = computed(() =>
|
||||||
new Intl.DateTimeFormat(undefined, {
|
new Intl.DateTimeFormat(undefined, {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -236,8 +254,15 @@ const toggleTheme = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const checkAiStatus = () => {
|
const checkAiStatus = () => {
|
||||||
|
if (!allowAiStyles.value) {
|
||||||
|
aiAvailable.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get('/api/ai-status')
|
.get('/api/ai-status', {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
aiAvailable.value = response.data.some((provider) => provider.available);
|
aiAvailable.value = response.data.some((provider) => provider.available);
|
||||||
})
|
})
|
||||||
@@ -258,7 +283,9 @@ const fetchImages = (options = { silent: false }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get('/api/images')
|
.get('/api/images', {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (Array.isArray(response.data)) {
|
if (Array.isArray(response.data)) {
|
||||||
images.value = response.data;
|
images.value = response.data;
|
||||||
@@ -294,7 +321,7 @@ const showContextMenu = (image) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const printImage = () => {
|
const printImage = () => {
|
||||||
if (!selectedImage.value) {
|
if (!selectedImage.value || !showPrintButton.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentOverlayComponent.value = 'printQuantityModal';
|
currentOverlayComponent.value = 'printQuantityModal';
|
||||||
@@ -311,7 +338,7 @@ const downloadImage = (imageParam = null) => {
|
|||||||
axios
|
axios
|
||||||
.post(
|
.post(
|
||||||
'/api/download-image',
|
'/api/download-image',
|
||||||
{ image_path: image.path },
|
{ image_path: image.path, gallery: gallerySlug.value },
|
||||||
{ responseType: 'blob' }
|
{ responseType: 'blob' }
|
||||||
)
|
)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
@@ -348,6 +375,7 @@ const handlePrintConfirmed = (quantity) => {
|
|||||||
image_id: identifier,
|
image_id: identifier,
|
||||||
image_path: selectedImage.value.path,
|
image_path: selectedImage.value.path,
|
||||||
quantity,
|
quantity,
|
||||||
|
gallery: gallerySlug.value,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
console.log('Print request sent successfully:', response.data);
|
console.log('Print request sent successfully:', response.data);
|
||||||
@@ -364,6 +392,11 @@ const handlePrintConfirmed = (quantity) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyStyle = (style, imageId) => {
|
const applyStyle = (style, imageId) => {
|
||||||
|
if (!allowAiStyles.value) {
|
||||||
|
showToast('Stilwechsel ist für diese Galerie deaktiviert.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetImageId = imageId ?? getImageIdentifier(selectedImage.value);
|
const targetImageId = imageId ?? getImageIdentifier(selectedImage.value);
|
||||||
if (!targetImageId) {
|
if (!targetImageId) {
|
||||||
showToast('Kein Bild ausgewählt.', 'error');
|
showToast('Kein Bild ausgewählt.', 'error');
|
||||||
@@ -379,6 +412,7 @@ const applyStyle = (style, imageId) => {
|
|||||||
.post('/api/images/style-change', {
|
.post('/api/images/style-change', {
|
||||||
image_id: targetImageId,
|
image_id: targetImageId,
|
||||||
style_id: style.id,
|
style_id: style.id,
|
||||||
|
gallery: gallerySlug.value,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
const promptId = response.data.prompt_id;
|
const promptId = response.data.prompt_id;
|
||||||
@@ -386,7 +420,9 @@ const applyStyle = (style, imageId) => {
|
|||||||
|
|
||||||
if (plugin === 'ComfyUi') {
|
if (plugin === 'ComfyUi') {
|
||||||
axios
|
axios
|
||||||
.get(`/api/comfyui-url?style_id=${style.id}`)
|
.get('/api/comfyui-url', {
|
||||||
|
params: { style_id: style.id, gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((comfyResponse) => {
|
.then((comfyResponse) => {
|
||||||
const comfyUiBaseUrl = comfyResponse.data.comfyui_url;
|
const comfyUiBaseUrl = comfyResponse.data.comfyui_url;
|
||||||
const wsUrl = `ws://${new URL(comfyUiBaseUrl).host}/ws`;
|
const wsUrl = `ws://${new URL(comfyUiBaseUrl).host}/ws`;
|
||||||
@@ -403,7 +439,9 @@ const applyStyle = (style, imageId) => {
|
|||||||
|
|
||||||
if (processingProgress.value >= 100) {
|
if (processingProgress.value >= 100) {
|
||||||
axios
|
axios
|
||||||
.get(`/api/images/fetch-styled/${promptId}`)
|
.get(`/api/images/fetch-styled/${promptId}`, {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((imageResponse) => {
|
.then((imageResponse) => {
|
||||||
styledImage.value = imageResponse.data.styled_image;
|
styledImage.value = imageResponse.data.styled_image;
|
||||||
currentOverlayComponent.value = 'styledImageDisplay';
|
currentOverlayComponent.value = 'styledImageDisplay';
|
||||||
@@ -443,7 +481,9 @@ const applyStyle = (style, imageId) => {
|
|||||||
} else {
|
} else {
|
||||||
const pollForStyledImage = () => {
|
const pollForStyledImage = () => {
|
||||||
axios
|
axios
|
||||||
.get(`/api/images/fetch-styled/${promptId}`)
|
.get(`/api/images/fetch-styled/${promptId}`, {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((imageResponse) => {
|
.then((imageResponse) => {
|
||||||
styledImage.value = imageResponse.data.styled_image;
|
styledImage.value = imageResponse.data.styled_image;
|
||||||
currentOverlayComponent.value = 'styledImageDisplay';
|
currentOverlayComponent.value = 'styledImageDisplay';
|
||||||
@@ -482,8 +522,28 @@ const keepStyledImage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteStyledImage = () => {
|
const deleteStyledImage = () => {
|
||||||
currentOverlayComponent.value = null;
|
if (!styledImage.value?.id) {
|
||||||
styledImage.value = null;
|
currentOverlayComponent.value = null;
|
||||||
|
styledImage.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
axios
|
||||||
|
.delete(`/api/images/${styledImage.value.id}/styled`, {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
showToast('Bild gelöscht.', 'success');
|
||||||
|
fetchImages({ silent: true });
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Error deleting styled image:', error);
|
||||||
|
showToast(error.response?.data?.error || 'Bild konnte nicht gelöscht werden.', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
currentOverlayComponent.value = null;
|
||||||
|
styledImage.value = null;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevPage = () => {
|
const prevPage = () => {
|
||||||
@@ -541,7 +601,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get('/api/image-refresh-interval')
|
.get('/api/image-refresh-interval', {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
startAutoRefresh(response.data.interval * 1000);
|
startAutoRefresh(response.data.interval * 1000);
|
||||||
})
|
})
|
||||||
@@ -551,7 +613,9 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get('/api/max-copies-setting')
|
.get('/api/max-copies-setting', {
|
||||||
|
params: { gallery: gallerySlug.value },
|
||||||
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
maxCopiesSetting.value = response.data.max_copies;
|
maxCopiesSetting.value = response.data.max_copies;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ return [
|
|||||||
'title' => 'Neu gestyltes Bild',
|
'title' => 'Neu gestyltes Bild',
|
||||||
'keep_button' => 'Behalten',
|
'keep_button' => 'Behalten',
|
||||||
'delete_button' => 'Löschen',
|
'delete_button' => 'Löschen',
|
||||||
|
'delete_confirm' => 'Bist du dir sicher?',
|
||||||
|
'keep_hint' => 'Speichere das Bild oder verwerfe es – das Original kannst du später erneut stylen.',
|
||||||
],
|
],
|
||||||
'print_command_sent_successfully' => 'Druckbefehl erfolgreich gesendet.',
|
'print_command_sent_successfully' => 'Druckbefehl erfolgreich gesendet.',
|
||||||
'failed_to_send_print_command' => 'Druckbefehl konnte nicht gesendet werden.',
|
'failed_to_send_print_command' => 'Druckbefehl konnte nicht gesendet werden.',
|
||||||
@@ -39,5 +41,17 @@ return [
|
|||||||
'tap_to_open' => 'Zum Öffnen tippen',
|
'tap_to_open' => 'Zum Öffnen tippen',
|
||||||
'empty' => 'Noch keine Bilder vorhanden.',
|
'empty' => 'Noch keine Bilder vorhanden.',
|
||||||
'new_badge' => 'Neu',
|
'new_badge' => 'Neu',
|
||||||
|
'access_title' => 'Galerie aufrufen',
|
||||||
|
'password_label' => 'Galerie-Passwort',
|
||||||
|
'submit_password' => 'Galerie betreten',
|
||||||
|
'expired' => 'Dieser Galerielink ist abgelaufen.',
|
||||||
|
'password_required' => 'Diese Galerie ist geschützt. Bitte gib das Passwort ein.',
|
||||||
|
'invalid_password' => 'Das Passwort war nicht korrekt.',
|
||||||
|
'duration_hint' => 'Nach dem ersten Entsperren bleibt der Zugriff :minutes Minuten aktiv.',
|
||||||
|
'expires_at_hint' => 'Link gültig bis :datetime.',
|
||||||
|
],
|
||||||
|
'loading_spinner' => [
|
||||||
|
'processing_image' => 'Dein Style wird angewendet',
|
||||||
|
'processing_wait' => 'Das kann einen Moment dauern. Danke für deine Geduld!',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ return [
|
|||||||
'image_refresh_interval' => 'Bildaktualisierungsintervall (Sekunden)',
|
'image_refresh_interval' => 'Bildaktualisierungsintervall (Sekunden)',
|
||||||
'new_image_timespan_minutes' => 'Neue Bilder Zeitspanne (Minuten)',
|
'new_image_timespan_minutes' => 'Neue Bilder Zeitspanne (Minuten)',
|
||||||
'gallery_heading' => 'Galerie Überschrift',
|
'gallery_heading' => 'Galerie Überschrift',
|
||||||
|
'require_gallery_password' => 'Galerie mit Passwort schützen',
|
||||||
|
'require_gallery_password_help' => 'Aktivieren, um Gäste vor dem Zugriff ein Passwort eingeben zu lassen.',
|
||||||
|
'gallery_password' => 'Neues Galerie-Passwort',
|
||||||
|
'gallery_password_help' => 'Leer lassen, um das aktuelle Passwort zu behalten. Deaktiviere den Schutz, um es zu entfernen.',
|
||||||
|
'gallery_expires_at' => 'Galerie läuft ab am',
|
||||||
|
'gallery_access_duration_minutes' => 'Zugriff aktiv halten für (Minuten)',
|
||||||
|
'gallery_access_duration_help' => 'Optionale Zeitspanne, die beim ersten Entsperren startet.',
|
||||||
|
'gallery_password_missing' => 'Bitte ein Passwort setzen oder den Passwortschutz deaktivieren.',
|
||||||
'max_number_of_copies' => 'Maximale Anzahl an Kopien',
|
'max_number_of_copies' => 'Maximale Anzahl an Kopien',
|
||||||
'show_print_button' => 'Button \'Drucken\' beim Bild anzeigen',
|
'show_print_button' => 'Button \'Drucken\' beim Bild anzeigen',
|
||||||
'printer' => 'Drucker',
|
'printer' => 'Drucker',
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ return [
|
|||||||
|
|
||||||
'saved_successfully' => 'Einstellungen erfolgreich gespeichert.',
|
'saved_successfully' => 'Einstellungen erfolgreich gespeichert.',
|
||||||
'save_button' => 'Speichern',
|
'save_button' => 'Speichern',
|
||||||
|
'cancel_button' => 'Abbrechen',
|
||||||
'new' => 'Neu',
|
'new' => 'Neu',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ return [
|
|||||||
'title' => 'Newly Styled Image',
|
'title' => 'Newly Styled Image',
|
||||||
'keep_button' => 'Keep',
|
'keep_button' => 'Keep',
|
||||||
'delete_button' => 'Delete',
|
'delete_button' => 'Delete',
|
||||||
|
'delete_confirm' => 'Are you sure?',
|
||||||
|
'keep_hint' => 'Save it or discard it – you can always restyle the original later.',
|
||||||
],
|
],
|
||||||
'print_command_sent_successfully' => 'Print command sent successfully.',
|
'print_command_sent_successfully' => 'Print command sent successfully.',
|
||||||
'failed_to_send_print_command' => 'Failed to send print command.',
|
'failed_to_send_print_command' => 'Failed to send print command.',
|
||||||
@@ -39,5 +41,17 @@ return [
|
|||||||
'tap_to_open' => 'Tap to open',
|
'tap_to_open' => 'Tap to open',
|
||||||
'empty' => 'No images available yet.',
|
'empty' => 'No images available yet.',
|
||||||
'new_badge' => 'New',
|
'new_badge' => 'New',
|
||||||
|
'access_title' => 'Access the gallery',
|
||||||
|
'password_label' => 'Gallery password',
|
||||||
|
'submit_password' => 'Enter gallery',
|
||||||
|
'expired' => 'This gallery link has expired.',
|
||||||
|
'password_required' => 'This gallery is protected. Please enter the password to continue.',
|
||||||
|
'invalid_password' => 'The password was incorrect.',
|
||||||
|
'duration_hint' => 'After the first unlock, access stays active for :minutes minutes.',
|
||||||
|
'expires_at_hint' => 'Link is valid until :datetime.',
|
||||||
|
],
|
||||||
|
'loading_spinner' => [
|
||||||
|
'processing_image' => 'Your style is being applied',
|
||||||
|
'processing_wait' => 'This may take a moment. Thanks for your patience!',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -129,6 +129,14 @@ return [
|
|||||||
'image_refresh_interval' => 'Image Refresh Interval (seconds)',
|
'image_refresh_interval' => 'Image Refresh Interval (seconds)',
|
||||||
'new_image_timespan_minutes' => 'New Image Timespan (minutes)',
|
'new_image_timespan_minutes' => 'New Image Timespan (minutes)',
|
||||||
'gallery_heading' => 'Gallery Heading',
|
'gallery_heading' => 'Gallery Heading',
|
||||||
|
'require_gallery_password' => 'Require gallery password',
|
||||||
|
'require_gallery_password_help' => 'Enable this to ask guests for a password before showing the gallery.',
|
||||||
|
'gallery_password' => 'New gallery password',
|
||||||
|
'gallery_password_help' => 'Leave empty to keep the current password. Disable password protection to clear it.',
|
||||||
|
'gallery_expires_at' => 'Gallery expires at',
|
||||||
|
'gallery_access_duration_minutes' => 'Keep access active for (minutes)',
|
||||||
|
'gallery_access_duration_help' => 'Optional rolling access window that starts when a guest unlocks the gallery.',
|
||||||
|
'gallery_password_missing' => 'Please provide a password or disable password protection.',
|
||||||
'max_number_of_copies' => 'Max Number of Copies',
|
'max_number_of_copies' => 'Max Number of Copies',
|
||||||
'show_print_button' => "Show 'Print' button on image",
|
'show_print_button' => "Show 'Print' button on image",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ return [
|
|||||||
|
|
||||||
'saved_successfully' => 'Settings saved successfully.',
|
'saved_successfully' => 'Settings saved successfully.',
|
||||||
'save_button' => 'Save',
|
'save_button' => 'Save',
|
||||||
|
'cancel_button' => 'Cancel',
|
||||||
'new' => 'New',
|
'new' => 'New',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@php
|
||||||
|
$url = $url ?? route('gallery.show', $record);
|
||||||
|
$path = $record?->images_path ?? 'uploads';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 bg-white/70 p-4 shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p>
|
||||||
|
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $url }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="navigator.clipboard.writeText('{{ $url }}')"
|
||||||
|
class="rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 shadow-sm transition hover:border-emerald-300 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 dark:border-white/20 dark:bg-white/10 dark:text-white"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 bg-white/70 p-4 shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Upload-Pfad</p>
|
||||||
|
<p class="mt-1 font-mono text-sm text-gray-800 dark:text-gray-200">storage/{{ $path }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm dark:border-white/20 dark:bg-white/5">
|
||||||
|
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(220)->margin(1)->generate($url) !!}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="data:image/png;base64,{{ base64_encode(\SimpleSoftwareIO\QrCode\Facades\QrCode::format('png')->size(400)->margin(1)->generate($url)) }}"
|
||||||
|
download="gallery-qr.png"
|
||||||
|
class="text-sm font-semibold text-emerald-600 underline underline-offset-4 hover:text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
QR als PNG herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
37
resources/views/filament/components/gallery-link.blade.php
Normal file
37
resources/views/filament/components/gallery-link.blade.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
@php
|
||||||
|
$url = $url ?? null;
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if ($url)
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 bg-white/70 p-4 shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Galerie-Link</p>
|
||||||
|
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $url }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="navigator.clipboard.writeText('{{ $url }}')"
|
||||||
|
class="rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-800 shadow-sm transition hover:border-emerald-300 hover:text-emerald-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 dark:border-white/20 dark:bg-white/10 dark:text-white"
|
||||||
|
>
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-center">
|
||||||
|
<div class="flex flex-col items-center gap-2">
|
||||||
|
<div class="rounded-lg border border-gray-200 bg-white p-2 shadow-sm dark:border-white/20 dark:bg-white/5">
|
||||||
|
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(200)->margin(1)->generate($url) !!}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="data:image/png;base64,{{ base64_encode(\SimpleSoftwareIO\QrCode\Facades\QrCode::format('png')->size(400)->margin(1)->generate($url)) }}"
|
||||||
|
download="gallery-qr.png"
|
||||||
|
class="text-sm font-semibold text-emerald-600 underline underline-offset-4 hover:text-emerald-700 dark:text-emerald-300"
|
||||||
|
>
|
||||||
|
QR als PNG herunterladen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
50
resources/views/filament/pages/sparkbooth-setup.blade.php
Normal file
50
resources/views/filament/pages/sparkbooth-setup.blade.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="space-y-6">
|
||||||
|
{{ $this->form }}
|
||||||
|
|
||||||
|
@if ($result)
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 bg-white/70 p-6 shadow-sm ring-1 ring-black/5 dark:border-white/10 dark:bg-white/5">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Upload Endpoint</p>
|
||||||
|
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $result['upload_url'] }}</p>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Upload Token</p>
|
||||||
|
<p class="mt-1 break-all font-mono text-sm text-gray-900 dark:text-white">{{ $result['upload_token'] }}</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Trage diesen Token in Sparkbooth unter „Upload Secret“ ein.</p>
|
||||||
|
</div>
|
||||||
|
<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">Galerie-Link</p>
|
||||||
|
<p class="mt-1 break-all font-semibold text-gray-900 dark:text-white">{{ $result['gallery_url'] }}</p>
|
||||||
|
<p class="mt-2 text-xs text-gray-500 dark:text-gray-300">Slug: {{ $result['gallery']['slug'] }}, Pfad: storage/{{ $result['gallery']['images_path'] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 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">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">QR Code (Galerie)</p>
|
||||||
|
<div class="mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-sm dark:border-white/20 dark:bg-white/5">
|
||||||
|
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(200)->margin(1)->generate($result['gallery_url']) !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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">QR Code (Upload)</p>
|
||||||
|
<div class="mt-2 rounded-lg border border-gray-200 bg-white p-2 shadow-sm dark:border-white/20 dark:bg-white/5">
|
||||||
|
{!! \SimpleSoftwareIO\QrCode\Facades\QrCode::size(200)->margin(1)->generate($result['upload_url'].'?token='.$result['upload_token']) !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="text-xs uppercase tracking-[0.3em] text-gray-500 dark:text-gray-300">Sparkbooth Beispiel (Custom Upload)</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">
|
||||||
|
POST {{ $result['upload_url'] }}
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
token={{ $result['upload_token'] }}
|
||||||
|
file=@your-photo.jpg
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
use App\Http\Controllers\Admin\NavigationStateController;
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use App\Http\Controllers\Api\AiStatusController;
|
use App\Http\Controllers\Api\AiStatusController;
|
||||||
use App\Http\Controllers\Api\ImageController;
|
use App\Http\Controllers\Api\ImageController;
|
||||||
|
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||||
use App\Http\Controllers\Api\StyleController;
|
use App\Http\Controllers\Api\StyleController;
|
||||||
|
use App\Http\Controllers\DownloadController;
|
||||||
|
use App\Http\Controllers\PrintController;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -17,35 +21,35 @@ use App\Http\Controllers\Api\StyleController;
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\NavigationStateController;
|
|
||||||
|
|
||||||
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
||||||
return $request->user();
|
return $request->user();
|
||||||
});
|
});
|
||||||
|
|
||||||
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'])
|
||||||
|
->middleware('throttle:30,1')
|
||||||
|
->name('api.sparkbooth.upload');
|
||||||
|
|
||||||
Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/admin/navigation-state', [NavigationStateController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
// Publicly accessible routes
|
Route::middleware('gallery.access')->group(function () {
|
||||||
Route::get('/images', [ImageController::class, 'index']);
|
Route::get('/images', [ImageController::class, 'index']);
|
||||||
Route::get('/styles', [StyleController::class, 'index']);
|
Route::get('/styles', [StyleController::class, 'index']);
|
||||||
Route::get('/image-refresh-interval', [StyleController::class, 'getImageRefreshInterval']);
|
Route::get('/image-refresh-interval', [StyleController::class, 'getImageRefreshInterval']);
|
||||||
Route::get('/max-copies-setting', [StyleController::class, 'getMaxNumberOfCopies']);
|
Route::get('/max-copies-setting', [StyleController::class, 'getMaxNumberOfCopies']);
|
||||||
|
|
||||||
Route::post('/images/style-change', [ImageController::class, 'styleChangeRequest']);
|
Route::post('/images/style-change', [ImageController::class, 'styleChangeRequest']);
|
||||||
Route::get('/comfyui-url', [ImageController::class, 'getComfyUiUrl']);
|
Route::get('/comfyui-url', [ImageController::class, 'getComfyUiUrl']);
|
||||||
|
Route::delete('/images/{image}/styled', [ImageController::class, 'deleteStyled']);
|
||||||
|
|
||||||
Route::middleware('auth:sanctum')->group(function () {
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
Route::post('/images/keep', [ImageController::class, 'keepImage']);
|
Route::post('/images/keep', [ImageController::class, 'keepImage']);
|
||||||
Route::delete('/images/{image}', [ImageController::class, 'deleteImage']);
|
Route::delete('/images/{image}', [ImageController::class, 'deleteImage']);
|
||||||
Route::get('/images/status', [ImageController::class, 'getStatus']);
|
Route::get('/images/status', [ImageController::class, 'getStatus']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::get('/images/fetch-styled/{prompt_id}', [ImageController::class, 'fetchStyledImage']);
|
||||||
|
Route::post('/print-image', [PrintController::class, 'printImage']);
|
||||||
|
Route::post('/download-image', [DownloadController::class, 'downloadImage']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/images/fetch-styled/{prompt_id}', [ImageController::class, 'fetchStyledImage']);
|
|
||||||
|
|
||||||
use App\Http\Controllers\PrintController;
|
|
||||||
use App\Http\Controllers\DownloadController;
|
|
||||||
Route::post('/print-image', [PrintController::class, 'printImage']);
|
|
||||||
Route::post('/download-image', [DownloadController::class, 'downloadImage']);
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\PluginController;
|
||||||
|
use App\Http\Controllers\GalleryAccessController;
|
||||||
|
use App\Http\Controllers\HomeController;
|
||||||
use App\Http\Controllers\ProfileController;
|
use App\Http\Controllers\ProfileController;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use App\Http\Controllers\LocaleController;
|
|
||||||
use App\Http\Controllers\Admin\PluginController;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@@ -18,7 +19,19 @@ use App\Http\Controllers\Admin\PluginController;
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
Route::get('/', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
|
Route::get('/gallery/access', [GalleryAccessController::class, 'create'])->name('gallery.access.default');
|
||||||
|
Route::post('/gallery/access', [GalleryAccessController::class, 'store'])->name('gallery.access.default.store');
|
||||||
|
|
||||||
|
Route::get('/g/{gallery:slug}/access', [GalleryAccessController::class, 'create'])->name('gallery.access.show');
|
||||||
|
Route::post('/g/{gallery:slug}/access', [GalleryAccessController::class, 'store'])->name('gallery.access.store');
|
||||||
|
|
||||||
|
Route::get('/', [HomeController::class, 'index'])
|
||||||
|
->middleware('gallery.access')
|
||||||
|
->name('home');
|
||||||
|
|
||||||
|
Route::get('/g/{gallery:slug}', [HomeController::class, 'index'])
|
||||||
|
->middleware('gallery.access')
|
||||||
|
->name('gallery.show');
|
||||||
|
|
||||||
Route::get('/login', function () {
|
Route::get('/login', function () {
|
||||||
return Inertia::render('Login');
|
return Inertia::render('Login');
|
||||||
@@ -34,4 +47,3 @@ Route::middleware('auth')->group(function () {
|
|||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/auth.php';
|
require __DIR__.'/auth.php';
|
||||||
|
|
||||||
|
|||||||
63
tests/Feature/GalleryAccessTest.php
Normal file
63
tests/Feature/GalleryAccessTest.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Gallery;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GalleryAccessTest extends TestCase
|
||||||
|
{
|
||||||
|
use DatabaseTransactions;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Artisan::call('migrate');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_redirects_to_access_page_when_password_is_required(): void
|
||||||
|
{
|
||||||
|
$gallery = Gallery::factory()->create([
|
||||||
|
'require_password' => true,
|
||||||
|
'password_hash' => Hash::make('secret123'),
|
||||||
|
'expires_at' => null,
|
||||||
|
'access_duration_minutes' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('gallery.show', $gallery));
|
||||||
|
$response->assertRedirect(route('gallery.access.show', $gallery));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_allows_access_after_correct_password(): void
|
||||||
|
{
|
||||||
|
$gallery = Gallery::factory()->create([
|
||||||
|
'require_password' => true,
|
||||||
|
'password_hash' => Hash::make('secret123'),
|
||||||
|
'expires_at' => null,
|
||||||
|
'access_duration_minutes' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->post(route('gallery.access.store', $gallery), ['password' => 'secret123'])
|
||||||
|
->assertRedirect(route('gallery.show', $gallery));
|
||||||
|
|
||||||
|
$this->get(route('gallery.show', $gallery))->assertOk();
|
||||||
|
$this->getJson('/api/images?gallery='.$gallery->slug)->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_denies_access_when_gallery_expired(): void
|
||||||
|
{
|
||||||
|
$gallery = Gallery::factory()->create([
|
||||||
|
'expires_at' => Carbon::now()->subMinute(),
|
||||||
|
'require_password' => false,
|
||||||
|
'access_duration_minutes' => null,
|
||||||
|
'password_hash' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->get(route('gallery.show', $gallery))->assertRedirect(route('gallery.access.show', $gallery));
|
||||||
|
$this->getJson('/api/images?gallery='.$gallery->slug)->assertForbidden();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user