From 9dbe848412837dc63b4592dec3ae1a463ec33137 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 15 Feb 2026 09:24:43 +0100 Subject: [PATCH] Restore toolbar background to bg-nebula; add toolbar backdrop blur --- app/Banner.php | 10 +- .../Controllers/Api/ArtworkTagController.php | 74 ++++++++++ app/Http/Controllers/Api/UploadController.php | 62 +++++++- app/Http/Resources/ArtworkListResource.php | 6 +- app/Http/Resources/ArtworkResource.php | 20 ++- app/Jobs/AutoTagArtworkJob.php | 40 +++++ app/Services/Artworks/ArtworkDraftService.php | 2 +- .../Uploads/UploadPipelineService.php | 17 +++ public/css/toolbar.css | 24 +-- resources/css/app.css | 12 +- .../js/components/upload/UploadStepper.jsx | 10 +- .../js/components/upload/UploadWizard.jsx | 131 ++++++++++++++++- resources/scss/nova.scss | 18 +-- resources/views/artworks/show.blade.php | 2 +- resources/views/blank.blade.php | 30 ++-- resources/views/layouts/app.blade.php | 4 +- resources/views/layouts/nova.blade.php | 6 +- resources/views/layouts/nova/footer.blade.php | 4 +- .../views/layouts/nova/toolbar.blade.php | 4 +- .../views/legacy/category-slug.blade.php | 2 +- resources/views/legacy/content-type.blade.php | 2 +- resources/views/legacy/photography.blade.php | 24 +-- resources/views/upload.blade.php | 45 +++--- routes/api.php | 1 + scripts/inspect_autotag_jobs.php | 58 ++++++++ scripts/repair_broken_artworks.php | 138 ++++++++++++++++++ scripts/verify_upload_publish.php | 98 +++++++++++++ tailwind.config.js | 2 +- 28 files changed, 736 insertions(+), 110 deletions(-) create mode 100644 scripts/inspect_autotag_jobs.php create mode 100644 scripts/repair_broken_artworks.php create mode 100644 scripts/verify_upload_publish.php diff --git a/app/Banner.php b/app/Banner.php index 2b32e884..de8e61b1 100644 --- a/app/Banner.php +++ b/app/Banner.php @@ -6,11 +6,11 @@ class Banner { public static function ShowResponsiveAd() { - echo '
'; - echo ''; - echo ''; - echo ''; - echo '
'; + #echo '
'; + #echo ''; + #echo ''; + #echo ''; + #echo '
'; } public static function ShowBanner300x250() diff --git a/app/Http/Controllers/Api/ArtworkTagController.php b/app/Http/Controllers/Api/ArtworkTagController.php index 2332b272..dda3b9cd 100644 --- a/app/Http/Controllers/Api/ArtworkTagController.php +++ b/app/Http/Controllers/Api/ArtworkTagController.php @@ -11,8 +11,10 @@ use App\Models\Artwork; use App\Models\Tag; use App\Services\TagService; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; +use App\Jobs\AutoTagArtworkJob; final class ArtworkTagController extends Controller { @@ -21,6 +23,78 @@ final class ArtworkTagController extends Controller ) { } + public function index(int $id): JsonResponse + { + $artwork = Artwork::query()->findOrFail($id); + $this->authorizeOrNotFound(request()->user(), $artwork); + + $queueConnection = (string) config('queue.default', 'sync'); + $visionEnabled = (bool) config('vision.enabled', true); + + $queuedCount = 0; + $failedCount = 0; + + if (in_array($queueConnection, ['database', 'redis'], true)) { + try { + $queuedCount = (int) DB::table('jobs') + ->where('payload', 'like', '%AutoTagArtworkJob%') + ->where('payload', 'like', '%' . $artwork->id . '%') + ->count(); + } catch (\Throwable) { + $queuedCount = 0; + } + + try { + $failedCount = (int) DB::table('failed_jobs') + ->where('payload', 'like', '%AutoTagArtworkJob%') + ->where('payload', 'like', '%' . $artwork->id . '%') + ->count(); + } catch (\Throwable) { + $failedCount = 0; + } + } + + $triggered = false; + $shouldTrigger = request()->boolean('trigger', false); + if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) { + AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash); + $triggered = true; + $queuedCount = max(1, $queuedCount); + } + + $tags = $artwork->tags() + ->select('tags.id', 'tags.name', 'tags.slug') + ->withPivot(['source', 'confidence']) + ->orderByDesc('artwork_tag.confidence') + ->get() + ->map(static function ($tag): array { + $source = (string) ($tag->pivot->source ?? 'manual'); + return [ + 'id' => (int) $tag->id, + 'name' => (string) $tag->name, + 'slug' => (string) $tag->slug, + 'source' => $source, + 'confidence' => (float) ($tag->pivot->confidence ?? 0), + 'is_ai' => $source === 'ai', + ]; + }) + ->values(); + + return response()->json([ + 'vision_enabled' => $visionEnabled, + 'tags' => $tags, + 'ai_tags' => $tags->where('is_ai', true)->values(), + 'debug' => [ + 'queue_connection' => $queueConnection, + 'queued_jobs' => $queuedCount, + 'failed_jobs' => $failedCount, + 'triggered' => $triggered, + 'ai_tag_count' => (int) $tags->where('is_ai', true)->count(), + 'total_tag_count' => (int) $tags->count(), + ], + ]); + } + public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse { $artwork = Artwork::query()->findOrFail($id); diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 9b780383..a3cee011 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -37,6 +37,8 @@ use App\Uploads\Exceptions\UploadPublishValidationException; use App\Uploads\Services\ArchiveInspectorService; use App\Uploads\Services\DraftQuotaService; use App\Uploads\Exceptions\DraftQuotaException; +use App\Models\Artwork; +use Illuminate\Support\Str; final class UploadController extends Controller { @@ -101,15 +103,13 @@ final class UploadController extends Controller } try { - $previewPath = null; - $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, &$previewPath) { + $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) { if ((bool) config('uploads.queue_derivatives', false)) { GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit(); return 'queued'; } - $result = $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId); - $previewPath = $result['public']['md'] ?? $result['public']['lg'] ?? null; + $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId); // Derivatives are available now; dispatch AI auto-tagging. AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); @@ -127,7 +127,6 @@ final class UploadController extends Controller return response()->json([ 'artwork_id' => $artworkId, 'status' => $status, - 'preview_path' => $previewPath, ], Response::HTTP_OK); } catch (Throwable $e) { Log::error('Upload finish failed', [ @@ -476,6 +475,58 @@ final class UploadController extends Controller { $user = $request->user(); + $validated = $request->validate([ + 'title' => ['nullable', 'string', 'max:150'], + 'description' => ['nullable', 'string'], + ]); + + if (ctype_digit($id)) { + $artworkId = (int) $id; + $artwork = Artwork::query()->find($artworkId); + if (! $artwork) { + return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND); + } + + if ((int) $artwork->user_id !== (int) $user->id) { + return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN); + } + + $title = trim((string) ($validated['title'] ?? $artwork->title ?? '')); + if ($title === '') { + $title = 'Untitled artwork'; + } + + $slugBase = Str::slug($title); + if ($slugBase === '') { + $slugBase = 'artwork'; + } + + $slug = $slugBase; + $suffix = 2; + while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) { + $slug = $slugBase . '-' . $suffix; + $suffix++; + } + + $artwork->title = $title; + if (array_key_exists('description', $validated)) { + $artwork->description = $validated['description']; + } + $artwork->slug = $slug; + $artwork->is_public = true; + $artwork->is_approved = true; + $artwork->published_at = now(); + $artwork->save(); + + return response()->json([ + 'success' => true, + 'artwork_id' => (int) $artwork->id, + 'status' => 'published', + 'slug' => (string) $artwork->slug, + 'published_at' => optional($artwork->published_at)->toISOString(), + ], Response::HTTP_OK); + } + try { $upload = $publishService->publish($id, $user); @@ -484,7 +535,6 @@ final class UploadController extends Controller 'upload_id' => (string) $upload->id, 'status' => (string) $upload->status, 'published_at' => optional($upload->published_at)->toISOString(), - 'final_path' => (string) $upload->final_path, ], Response::HTTP_OK); } catch (UploadOwnershipException $e) { return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); diff --git a/app/Http/Resources/ArtworkListResource.php b/app/Http/Resources/ArtworkListResource.php index 3914d1fa..77d29662 100644 --- a/app/Http/Resources/ArtworkListResource.php +++ b/app/Http/Resources/ArtworkListResource.php @@ -2,8 +2,8 @@ namespace App\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Storage; use Illuminate\Http\Resources\MissingValue; +use App\Services\ThumbnailService; class ArtworkListResource extends JsonResource { @@ -48,6 +48,8 @@ class ArtworkListResource extends JsonResource $categoryPath = $primaryCategory->full_slug_path ?? null; } $slugVal = $get('slug'); + $hash = (string) ($get('hash') ?? ''); + $thumbExt = (string) ($get('thumb_ext') ?? ''); $webUrl = $contentTypeSlug && $categoryPath && $slugVal ? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal : null; @@ -60,7 +62,7 @@ class ArtworkListResource extends JsonResource 'width' => $get('width'), 'height' => $get('height'), ], - 'thumbnail_url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))), + 'thumbnail_url' => $this->when(! empty($hash) && ! empty($thumbExt), fn() => ThumbnailService::fromHash($hash, $thumbExt, 'md')), 'author' => $this->whenLoaded('user', function () { return [ 'name' => $this->user->name ?? null, diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index e85db886..fb23ff1b 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -2,7 +2,6 @@ namespace App\Http\Resources; use Illuminate\Http\Resources\Json\JsonResource; -use Illuminate\Support\Facades\Storage; use Illuminate\Http\Resources\MissingValue; class ArtworkResource extends JsonResource @@ -32,6 +31,21 @@ class ArtworkResource extends JsonResource } return null; }; + $hash = (string) ($get('hash') ?? ''); + $fileExt = (string) ($get('file_ext') ?? ''); + $filesBase = rtrim((string) config('cdn.files_url', ''), '/'); + + $buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string { + $normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue)); + $normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue)); + if ($normalizedHash === '' || $normalizedExt === '') return null; + $h1 = substr($normalizedHash, 0, 2); + $h2 = substr($normalizedHash, 2, 2); + if ($h1 === '' || $h2 === '' || $filesBase === '') return null; + + return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt); + }; + return [ 'slug' => $get('slug'), 'title' => $get('title'), @@ -39,10 +53,10 @@ class ArtworkResource extends JsonResource 'width' => $get('width'), 'height' => $get('height'), - // File URLs: produce public URLs without exposing internal file_path + // File URLs are derived from hash/ext (no DB path dependency) 'file' => [ 'name' => $get('file_name') ?? null, - 'url' => $this->when(! empty($get('file_path')), fn() => Storage::url($get('file_path'))), + 'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)), 'size' => $get('file_size') ?? null, 'mime_type' => $get('mime_type') ?? null, ], diff --git a/app/Jobs/AutoTagArtworkJob.php b/app/Jobs/AutoTagArtworkJob.php index 1b236a1d..440a0692 100644 --- a/app/Jobs/AutoTagArtworkJob.php +++ b/app/Jobs/AutoTagArtworkJob.php @@ -14,6 +14,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Str; @@ -166,7 +167,9 @@ final class AutoTagArtworkJob implements ShouldQueue ->timeout(max(1, $timeout)) ->retry(max(0, $retries), max(0, $delay), throw: false) ->post($url, [ + 'url' => $imageUrl, 'image_url' => $imageUrl, + 'limit' => 8, 'artwork_id' => $this->artworkId, 'hash' => $this->hash, ]); @@ -182,6 +185,41 @@ final class AutoTagArtworkJob implements ShouldQueue if (! $response->ok()) { Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); + + // Fallback: try uploading the local derivative file to the gateway's file upload + // endpoint (`/analyze/all/file`) if the gateway cannot fetch the public URL. + try { + $variant = (string) config('vision.image_variant', 'md'); + $row = DB::table('artwork_files') + ->where('artwork_id', $this->artworkId) + ->where('variant', $variant) + ->first(); + + if ($row && ! empty($row->path)) { + $storageRoot = rtrim((string) config('uploads.storage_root', ''), DIRECTORY_SEPARATOR); + $absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $row->path); + if (is_file($absolute) && is_readable($absolute)) { + $uploadUrl = rtrim($base, '/') . '/analyze/all/file'; + try { + $attach = file_get_contents($absolute); + if ($attach !== false) { + $uploadResp = Http::attach('file', $attach, basename($absolute)) + ->post($uploadUrl, ['limit' => 5]); + + if ($uploadResp->ok()) { + return $this->extractTagList($uploadResp->json()); + } + Log::warning('CLIP upload fallback non-ok', ['ref' => $ref, 'status' => $uploadResp->status(), 'body' => $this->safeBody($uploadResp->body())]); + } + } catch (\Throwable $e) { + Log::warning('CLIP upload fallback failed', ['ref' => $ref, 'error' => $e->getMessage()]); + } + } + } + } catch (\Throwable $e) { + Log::warning('CLIP fallback check failed', ['ref' => $ref, 'error' => $e->getMessage()]); + } + return []; } @@ -216,7 +254,9 @@ final class AutoTagArtworkJob implements ShouldQueue ->timeout(max(1, $timeout)) ->retry(max(0, $retries), max(0, $delay), throw: false) ->post($url, [ + 'url' => $imageUrl, 'image_url' => $imageUrl, + 'conf' => 0.25, 'artwork_id' => $this->artworkId, 'hash' => $this->hash, ]); diff --git a/app/Services/Artworks/ArtworkDraftService.php b/app/Services/Artworks/ArtworkDraftService.php index d4c3e97e..7c18c374 100644 --- a/app/Services/Artworks/ArtworkDraftService.php +++ b/app/Services/Artworks/ArtworkDraftService.php @@ -22,7 +22,7 @@ final class ArtworkDraftService 'slug' => $slug, 'description' => $description, 'file_name' => 'pending', - 'file_path' => 'pending', + 'file_path' => '', 'file_size' => 0, 'mime_type' => 'application/octet-stream', 'width' => 1, diff --git a/app/Services/Uploads/UploadPipelineService.php b/app/Services/Uploads/UploadPipelineService.php index d311da8c..83446042 100644 --- a/app/Services/Uploads/UploadPipelineService.php +++ b/app/Services/Uploads/UploadPipelineService.php @@ -8,6 +8,7 @@ use App\DTOs\Uploads\UploadSessionData; use App\DTOs\Uploads\UploadInitResult; use App\DTOs\Uploads\UploadValidatedFile; use App\DTOs\Uploads\UploadScanResult; +use App\Models\Artwork; use App\Repositories\Uploads\ArtworkFileRepository; use App\Repositories\Uploads\UploadSessionRepository; use Illuminate\Http\UploadedFile; @@ -121,6 +122,22 @@ final class UploadPipelineService $publicRelative[$variant] = $relativePath; } + $dimensions = @getimagesize($session->tempPath); + $width = is_array($dimensions) && isset($dimensions[0]) ? (int) $dimensions[0] : 1; + $height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1; + + Artwork::query()->whereKey($artworkId)->update([ + 'file_name' => basename($originalRelative), + 'file_path' => '', + 'file_size' => (int) filesize($originalPath), + 'mime_type' => 'image/webp', + 'hash' => $hash, + 'file_ext' => 'webp', + 'thumb_ext' => 'webp', + 'width' => max(1, $width), + 'height' => max(1, $height), + ]); + $this->sessions->updateStatus($sessionId, UploadSessionStatus::PROCESSED); $this->sessions->updateProgress($sessionId, 100); $this->audit->log($session->userId, 'upload_processed', $session->ip, [ diff --git a/public/css/toolbar.css b/public/css/toolbar.css index 9224a6e1..fed756bd 100644 --- a/public/css/toolbar.css +++ b/public/css/toolbar.css @@ -564,7 +564,9 @@ subLoginMenu ul { margin:0; padding:0; height:50px; - background:rgba(30,30,30,0.2); + background:rgba(30,30,30,0.2); + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); } .nav>li.menu_notice>a { @@ -583,10 +585,12 @@ subLoginMenu ul { } .navbar-skinbase { - background:rgba(16, 25, 33, 0.9); - border-bottom:solid 1px #000; - box-shadow:0 0 14px #333; - z-index:1000; + background: rgba(16, 25, 33, 0.6); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + border-bottom:solid 1px #000; + box-shadow:0 0 14px #333; + z-index:1000; } .dropdown:hover .dropdown-menu { @@ -671,10 +675,12 @@ subLoginMenu ul { } .navbar-skinbase .dropdown-menu { - background:rgba(16, 25, 33, 0.9); - border-bottom:solid 1px #000; - box-shadow:0 0 14px #333; - color:#fff; + background: rgba(16, 25, 33, 0.6); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); + border-bottom:solid 1px #000; + box-shadow:0 0 14px #333; + color:#fff; } diff --git a/resources/css/app.css b/resources/css/app.css index 9231bd52..40fa5a9e 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -7,13 +7,13 @@ } .form-input { - @apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2 + @apply w-full bg-deep border border-nova-500/30 rounded-lg px-4 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent; } .form-textarea { - @apply w-full bg-deep border border-nebula-500/30 rounded-lg px-4 py-2 + @apply w-full bg-deep border border-nova-500/30 rounded-lg px-4 py-2 text-white resize-none focus:outline-none focus:ring-2 focus:ring-accent; } @@ -22,7 +22,7 @@ @apply w-full text-sm text-soft file:bg-panel file:border-0 file:px-4 file:py-2 file:rounded-lg file:text-white - hover:file:bg-nebula-600/40; + hover:file:bg-nova-600/40; } .btn-primary { @@ -31,8 +31,8 @@ } .btn-secondary { - @apply bg-nebula-500/30 text-white px-5 py-2 rounded-lg - hover:bg-nebula-500/50 transition; + @apply bg-nova-500/30 text-white px-5 py-2 rounded-lg + hover:bg-nova-500/50 transition; } @layer components { @@ -44,7 +44,7 @@ input[type="password"], textarea, select { - @apply bg-deep text-white border border-nebula-500/30 rounded-lg px-4 py-2 + @apply bg-deep text-white border border-nova-500/30 rounded-lg px-4 py-2 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent; } diff --git a/resources/js/components/upload/UploadStepper.jsx b/resources/js/components/upload/UploadStepper.jsx index df57f074..1649bb81 100644 --- a/resources/js/components/upload/UploadStepper.jsx +++ b/resources/js/components/upload/UploadStepper.jsx @@ -5,7 +5,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc return (