register for sessions. photos with different settings, settings configurable
This commit is contained in:
parent
5fd29ef5f3
commit
17c1c2a521
@ -20,9 +20,12 @@ class ConvertToComic implements ShouldQueue
|
||||
{
|
||||
$absolutePath = Storage::disk('public')->path($this->photoJob->image_path);
|
||||
|
||||
$gmicOps = $this->photoJob->setting?->gmic_command
|
||||
?? 'cl_comic 4,1,0,0,1,15,15,1,10,20,6,2,0,0,0,0,0,0,50,50';
|
||||
|
||||
$result = Process::timeout(0)->run(
|
||||
'gmic ' . escapeshellarg($absolutePath) .
|
||||
' cl_comic 4,1,0,0,1,15,15,1,10,20,6,2,0,0,0,0,0,0,50,50' .
|
||||
' ' . $gmicOps .
|
||||
' -o ' . escapeshellarg($absolutePath)
|
||||
);
|
||||
|
||||
|
||||
@ -4,6 +4,8 @@ namespace App\Livewire;
|
||||
|
||||
use App\Jobs\ConvertToComic;
|
||||
use App\Models\PhotoJob;
|
||||
use App\Models\PhotoSession;
|
||||
use App\Models\PhotoSetting;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
@ -12,17 +14,6 @@ class CameraCapture extends Component
|
||||
{
|
||||
public string $status = '';
|
||||
|
||||
public function delete(int $jobId): void
|
||||
{
|
||||
$job = PhotoJob::find($jobId);
|
||||
if (!$job) return;
|
||||
|
||||
Storage::disk('public')->delete($job->image_path);
|
||||
$job->delete();
|
||||
|
||||
$this->status = "Job #{$jobId} deleted";
|
||||
}
|
||||
|
||||
public function capture(string $imageData): void
|
||||
{
|
||||
if (!preg_match('/^data:image\/(\w+);base64,/', $imageData, $type)) {
|
||||
@ -31,31 +22,66 @@ class CameraCapture extends Component
|
||||
}
|
||||
|
||||
$extension = strtolower($type[1]);
|
||||
$filename = 'photos/' . Str::uuid() . '.' . $extension;
|
||||
$raw = base64_decode(substr($imageData, strpos($imageData, ',') + 1));
|
||||
|
||||
Storage::disk('public')->put($filename, $raw);
|
||||
$originalPath = 'photos/' . Str::uuid() . '.' . $extension;
|
||||
Storage::disk('public')->put($originalPath, $raw);
|
||||
|
||||
$job = PhotoJob::create([
|
||||
'image_path' => $filename,
|
||||
'status' => 'processing',
|
||||
]);
|
||||
$session = PhotoSession::create(['original_image_path' => $originalPath]);
|
||||
|
||||
ConvertToComic::dispatch($job);
|
||||
$settings = PhotoSetting::active()->get();
|
||||
|
||||
$this->status = "Processing photo…";
|
||||
foreach ($settings as $setting) {
|
||||
$variantPath = 'photos/' . Str::uuid() . '.' . $extension;
|
||||
Storage::disk('public')->put($variantPath, $raw);
|
||||
|
||||
$job = PhotoJob::create([
|
||||
'image_path' => $variantPath,
|
||||
'status' => 'processing',
|
||||
'photo_session_id' => $session->id,
|
||||
'photo_setting_id' => $setting->id,
|
||||
]);
|
||||
|
||||
ConvertToComic::dispatch($job);
|
||||
}
|
||||
|
||||
$this->status = count($settings) . ' Varianten werden erstellt…';
|
||||
}
|
||||
|
||||
public function selectVariant(int $sessionId, int $jobId): void
|
||||
{
|
||||
PhotoSession::where('id', $sessionId)->update(['selected_job_id' => $jobId]);
|
||||
}
|
||||
|
||||
public function deleteSession(int $sessionId): void
|
||||
{
|
||||
$session = PhotoSession::with('jobs')->find($sessionId);
|
||||
if (!$session) return;
|
||||
|
||||
foreach ($session->jobs as $job) {
|
||||
Storage::disk('public')->delete($job->image_path);
|
||||
$job->delete();
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($session->original_image_path);
|
||||
$session->delete();
|
||||
|
||||
$this->status = "Session #{$sessionId} gelöscht";
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$recentPhotos = PhotoJob::latest()->take(10)->get()->map(fn($job) => [
|
||||
'id' => $job->id,
|
||||
'url' => Storage::disk('public')->url($job->image_path) . '?v=' . $job->updated_at->timestamp,
|
||||
'status' => $job->status,
|
||||
]);
|
||||
$sessions = PhotoSession::latest()->take(10)->get();
|
||||
|
||||
$hasProcessing = $recentPhotos->contains('status', 'processing');
|
||||
foreach ($sessions as $session) {
|
||||
$session->setRelation('jobs', $session->jobs()->with('setting')->orderBy('photo_setting_id')->get());
|
||||
}
|
||||
|
||||
return view('livewire.camera-capture', compact('recentPhotos', 'hasProcessing'));
|
||||
$currentSession = $sessions->first();
|
||||
$previousSessions = $sessions->skip(1);
|
||||
|
||||
$hasProcessing = $currentSession && $currentSession->jobs->contains('status', 'processing');
|
||||
|
||||
return view('livewire.camera-capture', compact('currentSession', 'previousSessions', 'hasProcessing'));
|
||||
}
|
||||
}
|
||||
|
||||
75
app/Livewire/PhotoSettings.php
Normal file
75
app/Livewire/PhotoSettings.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\PhotoSetting;
|
||||
use Livewire\Component;
|
||||
|
||||
class PhotoSettings extends Component
|
||||
{
|
||||
public array $editingId = [];
|
||||
public array $editName = [];
|
||||
public array $editCommand = [];
|
||||
public bool $showForm = false;
|
||||
public string $newName = '';
|
||||
public string $newCommand = '';
|
||||
|
||||
public function toggleActive(int $id): void
|
||||
{
|
||||
$setting = PhotoSetting::findOrFail($id);
|
||||
$setting->update(['is_active' => !$setting->is_active]);
|
||||
}
|
||||
|
||||
public function startEdit(int $id, string $name, string $command): void
|
||||
{
|
||||
$this->editingId = [$id => true];
|
||||
$this->editName[$id] = $name;
|
||||
$this->editCommand[$id] = $command;
|
||||
}
|
||||
|
||||
public function saveEdit(int $id): void
|
||||
{
|
||||
PhotoSetting::findOrFail($id)->update([
|
||||
'name' => trim($this->editName[$id] ?? ''),
|
||||
'gmic_command' => trim($this->editCommand[$id] ?? ''),
|
||||
]);
|
||||
unset($this->editingId[$id]);
|
||||
}
|
||||
|
||||
public function cancelEdit(int $id): void
|
||||
{
|
||||
unset($this->editingId[$id]);
|
||||
}
|
||||
|
||||
public function addSetting(): void
|
||||
{
|
||||
$name = trim($this->newName);
|
||||
$command = trim($this->newCommand);
|
||||
|
||||
if (!$name || !$command) return;
|
||||
|
||||
$max = PhotoSetting::max('sort_order') ?? 0;
|
||||
PhotoSetting::create([
|
||||
'name' => $name,
|
||||
'gmic_command' => $command,
|
||||
'sort_order' => $max + 1,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->newName = '';
|
||||
$this->newCommand = '';
|
||||
$this->showForm = false;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
PhotoSetting::findOrFail($id)->delete();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.photo-settings', [
|
||||
'settings' => PhotoSetting::orderBy('sort_order')->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,19 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoJob extends Model
|
||||
{
|
||||
protected $fillable = ['image_path', 'status'];
|
||||
protected $fillable = ['image_path', 'status', 'photo_session_id', 'photo_setting_id'];
|
||||
|
||||
public function session(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PhotoSession::class, 'photo_session_id');
|
||||
}
|
||||
|
||||
public function setting(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PhotoSetting::class, 'photo_setting_id');
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Models/PhotoSession.php
Normal file
22
app/Models/PhotoSession.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoSession extends Model
|
||||
{
|
||||
protected $fillable = ['original_image_path', 'selected_job_id'];
|
||||
|
||||
public function jobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(PhotoJob::class)->with('setting')->orderBy('photo_setting_id');
|
||||
}
|
||||
|
||||
public function selectedJob(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PhotoJob::class, 'selected_job_id');
|
||||
}
|
||||
}
|
||||
23
app/Models/PhotoSetting.php
Normal file
23
app/Models/PhotoSetting.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PhotoSetting extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'gmic_command', 'sort_order', 'is_active'];
|
||||
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function jobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(PhotoJob::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true)->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
<?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('photo_jobs', function (Blueprint $table) {
|
||||
$table->foreignId('photo_session_id')->nullable()->constrained('photo_sessions')->nullOnDelete();
|
||||
$table->foreignId('photo_setting_id')->nullable()->constrained('photo_settings')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('photo_jobs', function (Blueprint $table) {
|
||||
$table->dropForeign(['photo_session_id']);
|
||||
$table->dropForeign(['photo_setting_id']);
|
||||
$table->dropColumn(['photo_session_id', 'photo_setting_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?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::create('photo_sessions', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('original_image_path');
|
||||
$table->unsignedBigInteger('selected_job_id')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photo_sessions');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?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::create('photo_settings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->text('gmic_command');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photo_settings');
|
||||
}
|
||||
};
|
||||
@ -15,11 +15,6 @@ class DatabaseSeeder extends Seeder
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$this->call(PhotoSettingSeeder::class);
|
||||
}
|
||||
}
|
||||
|
||||
37
database/seeders/PhotoSettingSeeder.php
Normal file
37
database/seeders/PhotoSettingSeeder.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\PhotoSetting;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PhotoSettingSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$settings = [
|
||||
[
|
||||
'name' => 'Comic',
|
||||
'gmic_command' => 'cl_comic 4,1,0,0,1,15,15,1,10,20,6,2,0,0,0,0,0,0,50,50',
|
||||
'sort_order' => 1,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Schwarzweiß',
|
||||
'gmic_command' => '-to_gray',
|
||||
'sort_order' => 2,
|
||||
'is_active' => true,
|
||||
],
|
||||
[
|
||||
'name' => 'Weichzeichner',
|
||||
'gmic_command' => '-blur 4 -sharpen 80',
|
||||
'sort_order' => 3,
|
||||
'is_active' => true,
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($settings as $setting) {
|
||||
PhotoSetting::firstOrCreate(['name' => $setting['name']], $setting);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@
|
||||
|
||||
@livewire('camera-capture')
|
||||
|
||||
@livewire('photo-settings')
|
||||
|
||||
@livewireScripts
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
<div class="flex flex-col items-center w-full"
|
||||
x-data="{ lightbox: null }"
|
||||
x-data="{ activeTab: 'current', lightbox: null }"
|
||||
x-on:keydown.escape.window="lightbox = null">
|
||||
|
||||
{{-- Camera preview --}}
|
||||
<div class="relative w-full max-w-2xl">
|
||||
<video id="video" autoplay playsinline
|
||||
class="w-full rounded-2xl shadow-2xl bg-gray-900 aspect-video object-cover" style="transform: scaleX(-1);"></video>
|
||||
@ -19,46 +20,136 @@
|
||||
wire:loading.attr="disabled"
|
||||
@disabled($hasProcessing)
|
||||
class="mt-8 px-10 py-4 bg-white text-gray-950 font-semibold text-lg rounded-full shadow-lg hover:bg-gray-200 active:scale-95 transition-all duration-150 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span wire:loading.remove>{{ $hasProcessing ? 'Erstelle Bild...' : 'Foto aufnehmen' }}</span>
|
||||
<span wire:loading>Saving…</span>
|
||||
<span wire:loading.remove>{{ $hasProcessing ? 'Erstelle Varianten...' : 'Foto aufnehmen' }}</span>
|
||||
<span wire:loading>Speichern…</span>
|
||||
</button>
|
||||
|
||||
<div class="mt-4 text-sm text-gray-400 h-6">{{ $status }}</div>
|
||||
|
||||
@if ($recentPhotos->isNotEmpty())
|
||||
<div class="mt-10 w-full max-w-2xl">
|
||||
<h2 class="text-sm font-medium text-gray-400 mb-3 tracking-wide uppercase">Recent Photos</h2>
|
||||
<div class="grid grid-cols-5 gap-2" @if($hasProcessing) wire:poll.2000ms @endif>
|
||||
@foreach ($recentPhotos as $photo)
|
||||
<div class="relative">
|
||||
@if ($photo['status'] === 'processing')
|
||||
<div class="relative w-full aspect-square">
|
||||
<img src="{{ $photo['url'] }}" alt="Photo #{{ $photo['id'] }}"
|
||||
class="w-full aspect-square object-cover rounded-lg border border-gray-700">
|
||||
<div class="absolute inset-0 rounded-lg bg-black/50 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-white animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<img src="{{ $photo['url'] }}" alt="Photo #{{ $photo['id'] }}"
|
||||
class="w-full aspect-square object-cover rounded-lg border border-gray-700 cursor-pointer"
|
||||
x-on:click="lightbox = {{ Js::from($photo) }}">
|
||||
<button wire:click="delete({{ $photo['id'] }})" wire:confirm="Delete this photo?"
|
||||
class="absolute top-1 right-1 p-1 rounded bg-black/60 text-white hover:bg-red-600/80 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
@endif
|
||||
<span class="absolute bottom-1 left-1 text-[10px] px-1.5 py-0.5 rounded bg-black/60 text-yellow-400">
|
||||
{{ $photo['status'] }}
|
||||
</span>
|
||||
</div>
|
||||
{{-- Tabbed photo sessions --}}
|
||||
@if ($currentSession || $previousSessions->isNotEmpty())
|
||||
<div class="mt-10 w-full max-w-4xl" @if($hasProcessing) wire:poll.2000ms @endif>
|
||||
|
||||
{{-- Tab navigation --}}
|
||||
<div class="flex gap-1 mb-4 border-b border-gray-800 overflow-x-auto">
|
||||
<button x-on:click="activeTab = 'current'"
|
||||
:class="activeTab === 'current' ? 'border-b-2 border-white text-white' : 'text-gray-500 hover:text-gray-300'"
|
||||
class="pb-2 px-4 text-sm font-medium whitespace-nowrap transition-colors">
|
||||
Aktuell
|
||||
</button>
|
||||
@foreach ($previousSessions as $session)
|
||||
<button x-on:click="activeTab = 'session{{ $session->id }}'"
|
||||
:class="activeTab === 'session{{ $session->id }}' ? 'border-b-2 border-white text-white' : 'text-gray-500 hover:text-gray-300'"
|
||||
class="pb-2 px-4 text-sm font-medium whitespace-nowrap transition-colors">
|
||||
#{{ $session->id }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Current session tab --}}
|
||||
<div x-show="activeTab === 'current'">
|
||||
@if ($currentSession)
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
|
||||
{{-- Original for comparison --}}
|
||||
<div class="relative group">
|
||||
<img src="{{ Storage::disk('public')->url($currentSession->original_image_path) }}"
|
||||
alt="Original"
|
||||
class="w-full aspect-square object-cover rounded-xl border-2 border-blue-500 cursor-pointer"
|
||||
x-on:click="lightbox = {{ Js::from(['url' => Storage::disk('public')->url($currentSession->original_image_path)]) }}">
|
||||
<span class="absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded-full bg-blue-600/80 text-white font-medium">
|
||||
Original
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{{-- Variants --}}
|
||||
@foreach ($currentSession->jobs as $job)
|
||||
<div class="relative group cursor-pointer"
|
||||
wire:key="job-{{ $job->id }}"
|
||||
@if($job->status === 'done') x-on:click="$wire.selectVariant({{ $currentSession->id }}, {{ $job->id }})" @endif>
|
||||
|
||||
@if ($job->status === 'processing')
|
||||
<div class="w-full aspect-square rounded-xl border border-gray-700 bg-gray-900 flex items-center justify-center">
|
||||
<svg class="w-8 h-8 text-gray-400 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
@elseif ($job->status === 'failed')
|
||||
<div class="w-full aspect-square rounded-xl border border-red-900 bg-gray-900 flex items-center justify-center">
|
||||
<span class="text-red-500 text-xs">Fehler</span>
|
||||
</div>
|
||||
@else
|
||||
<img src="{{ Storage::disk('public')->url($job->image_path) }}?v={{ $job->updated_at->timestamp }}"
|
||||
alt="{{ $job->setting?->name }}"
|
||||
class="w-full aspect-square object-cover rounded-xl border-2 transition-colors {{ $currentSession->selected_job_id === $job->id ? 'border-green-500' : 'border-gray-700 group-hover:border-gray-400' }}">
|
||||
@if ($currentSession->selected_job_id === $job->id)
|
||||
<div class="absolute top-2 right-2 p-1 rounded-full bg-green-500 shadow">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3 text-white" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
<span class="absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded-full bg-black/60 text-yellow-400 font-medium">
|
||||
{{ $job->setting?->name ?? 'Filter' }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
|
||||
<button wire:click="deleteSession({{ $currentSession->id }})" wire:confirm="Diese Session löschen?"
|
||||
class="mt-4 px-4 py-1.5 text-xs rounded-lg bg-gray-900 text-gray-500 hover:text-red-400 hover:bg-gray-800 transition-colors border border-gray-800">
|
||||
Session löschen
|
||||
</button>
|
||||
@else
|
||||
<p class="text-gray-600 text-sm text-center py-12">Noch kein Foto aufgenommen.</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Previous session tabs --}}
|
||||
@foreach ($previousSessions as $session)
|
||||
<div x-show="activeTab === 'session{{ $session->id }}'">
|
||||
@php
|
||||
$displayJob = $session->selected_job_id
|
||||
? $session->jobs->firstWhere('id', $session->selected_job_id)
|
||||
: $session->jobs->where('status', 'done')->first();
|
||||
@endphp
|
||||
|
||||
@if ($displayJob)
|
||||
<img src="{{ Storage::disk('public')->url($displayJob->image_path) }}?v={{ $displayJob->updated_at->timestamp }}"
|
||||
alt="Session #{{ $session->id }}"
|
||||
class="w-full max-w-lg mx-auto rounded-2xl border border-gray-700 cursor-pointer mb-4"
|
||||
x-on:click="lightbox = {{ Js::from(['url' => Storage::disk('public')->url($displayJob->image_path) . '?v=' . $displayJob->updated_at->timestamp]) }}">
|
||||
@endif
|
||||
|
||||
{{-- Variant thumbnails --}}
|
||||
@if ($session->jobs->count() > 1)
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 gap-2 mt-2">
|
||||
@foreach ($session->jobs->where('status', 'done') as $job)
|
||||
<div class="relative cursor-pointer group"
|
||||
wire:key="prev-job-{{ $job->id }}"
|
||||
x-on:click="$wire.selectVariant({{ $session->id }}, {{ $job->id }})">
|
||||
<img src="{{ Storage::disk('public')->url($job->image_path) }}?v={{ $job->updated_at->timestamp }}"
|
||||
alt="{{ $job->setting?->name }}"
|
||||
class="w-full aspect-square object-cover rounded-lg border-2 transition-colors {{ $session->selected_job_id === $job->id ? 'border-green-500' : 'border-gray-700 group-hover:border-gray-500' }}">
|
||||
<span class="absolute bottom-1 left-1 text-[10px] px-1 py-0.5 rounded bg-black/60 text-yellow-400 truncate max-w-[90%]">
|
||||
{{ $job->setting?->name ?? 'Filter' }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<button wire:click="deleteSession({{ $session->id }})" wire:confirm="Session #{{ $session->id }} löschen?"
|
||||
class="mt-4 px-4 py-1.5 text-xs rounded-lg bg-gray-900 text-gray-500 hover:text-red-400 hover:bg-gray-800 transition-colors border border-gray-800">
|
||||
Session löschen
|
||||
</button>
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@ -69,8 +160,7 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-6"
|
||||
style="display:none">
|
||||
<div class="relative max-w-2xl w-full">
|
||||
<img x-bind:src="lightbox?.url" alt="Photo"
|
||||
class="w-full rounded-2xl shadow-2xl">
|
||||
<img x-bind:src="lightbox?.url" alt="Photo" class="w-full rounded-2xl shadow-2xl">
|
||||
<div class="absolute bottom-4 right-4 rounded-xl overflow-hidden shadow-2xl bg-white p-1">
|
||||
<canvas x-ref="qrCanvas"
|
||||
x-effect="lightbox && $nextTick(() => window.generateQR(lightbox.url, $refs.qrCanvas))"></canvas>
|
||||
|
||||
69
resources/views/livewire/photo-settings.blade.php
Normal file
69
resources/views/livewire/photo-settings.blade.php
Normal file
@ -0,0 +1,69 @@
|
||||
<div class="w-full max-w-2xl mt-10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-medium text-gray-400 tracking-wide uppercase">Foto-Einstellungen</h2>
|
||||
<button wire:click="$set('showForm', !$showForm)"
|
||||
class="text-xs px-3 py-1 rounded bg-gray-800 text-gray-300 hover:bg-gray-700 transition-colors">
|
||||
{{ $showForm ? 'Abbrechen' : '+ Neu' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if ($showForm)
|
||||
<div class="mb-4 p-4 rounded-xl bg-gray-900 border border-gray-700 space-y-2">
|
||||
<input wire:model="newName" type="text" placeholder="Name (z.B. Ölgemälde)"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 focus:outline-none focus:border-gray-500">
|
||||
<input wire:model="newCommand" type="text" placeholder="GMIC-Befehl (z.B. -to_gray)"
|
||||
class="w-full px-3 py-2 text-sm rounded-lg bg-gray-800 border border-gray-700 text-white placeholder-gray-500 font-mono focus:outline-none focus:border-gray-500">
|
||||
<button wire:click="addSetting"
|
||||
class="px-4 py-2 text-sm rounded-lg bg-white text-gray-950 font-medium hover:bg-gray-200 transition-colors">
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="space-y-2">
|
||||
@foreach ($settings as $setting)
|
||||
<div class="flex items-center gap-3 p-3 rounded-xl bg-gray-900 border border-gray-800">
|
||||
<button wire:click="toggleActive({{ $setting->id }})"
|
||||
class="flex-shrink-0 w-9 h-5 rounded-full transition-colors {{ $setting->is_active ? 'bg-green-500' : 'bg-gray-700' }} relative">
|
||||
<span class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white transition-transform {{ $setting->is_active ? 'translate-x-4' : 'translate-x-0' }}"></span>
|
||||
</button>
|
||||
|
||||
@if (isset($editingId[$setting->id]))
|
||||
<div class="flex-1 flex gap-2">
|
||||
<input wire:model="editName.{{ $setting->id }}" type="text"
|
||||
class="flex-1 px-2 py-1 text-sm rounded bg-gray-800 border border-gray-600 text-white focus:outline-none">
|
||||
<input wire:model="editCommand.{{ $setting->id }}" type="text"
|
||||
class="flex-1 px-2 py-1 text-sm rounded bg-gray-800 border border-gray-600 text-white font-mono focus:outline-none">
|
||||
<button wire:click="saveEdit({{ $setting->id }})"
|
||||
class="px-3 py-1 text-xs rounded bg-green-700 text-white hover:bg-green-600">Speichern</button>
|
||||
<button wire:click="cancelEdit({{ $setting->id }})"
|
||||
class="px-3 py-1 text-xs rounded bg-gray-700 text-white hover:bg-gray-600">×</button>
|
||||
</div>
|
||||
@else
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white">{{ $setting->name }}</p>
|
||||
<p class="text-xs text-gray-500 font-mono truncate">{{ $setting->gmic_command }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button wire:click="startEdit({{ $setting->id }}, {{ Js::from($setting->name) }}, {{ Js::from($setting->gmic_command) }})"
|
||||
class="p-1.5 rounded text-gray-400 hover:text-white hover:bg-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button wire:click="delete({{ $setting->id }})" wire:confirm="Einstellung löschen?"
|
||||
class="p-1.5 rounded text-gray-600 hover:text-red-400 hover:bg-gray-700 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
|
||||
@if ($settings->isEmpty())
|
||||
<p class="text-sm text-gray-600 text-center py-4">Keine Einstellungen vorhanden.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
Loading…
Reference in New Issue
Block a user