Restore toolbar background to bg-nebula; add toolbar backdrop blur

This commit is contained in:
2026-02-15 09:24:43 +01:00
parent 79192345e3
commit 9dbe848412
28 changed files with 736 additions and 110 deletions

View File

@@ -6,11 +6,11 @@ class Banner
{ {
public static function ShowResponsiveAd() public static function ShowResponsiveAd()
{ {
echo '<div class="responsive_ad">'; #echo '<div class="responsive_ad">';
echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>'; #echo '<script async src="//pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>';
echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>'; #echo '<ins class="adsbygoogle" style="display:block" data-ad-client="ca-pub-6457864535683080" data-ad-slot="9918154676" data-ad-format="auto"></ins>';
echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>'; #echo '<script>(adsbygoogle = window.adsbygoogle || []).push({});</script>';
echo '</div>'; #echo '</div>';
} }
public static function ShowBanner300x250() public static function ShowBanner300x250()

View File

@@ -11,8 +11,10 @@ use App\Models\Artwork;
use App\Models\Tag; use App\Models\Tag;
use App\Services\TagService; use App\Services\TagService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use App\Jobs\AutoTagArtworkJob;
final class ArtworkTagController extends Controller 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 public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
{ {
$artwork = Artwork::query()->findOrFail($id); $artwork = Artwork::query()->findOrFail($id);

View File

@@ -37,6 +37,8 @@ use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Services\ArchiveInspectorService; use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService; use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException; use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use Illuminate\Support\Str;
final class UploadController extends Controller final class UploadController extends Controller
{ {
@@ -101,15 +103,13 @@ final class UploadController extends Controller
} }
try { try {
$previewPath = null; $status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId) {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, &$previewPath) {
if ((bool) config('uploads.queue_derivatives', false)) { if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit(); GenerateDerivativesJob::dispatch($sessionId, $validated->hash, $artworkId)->afterCommit();
return 'queued'; return 'queued';
} }
$result = $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId); $pipeline->processAndPublish($sessionId, $validated->hash, $artworkId);
$previewPath = $result['public']['md'] ?? $result['public']['lg'] ?? null;
// Derivatives are available now; dispatch AI auto-tagging. // Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit(); AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
@@ -127,7 +127,6 @@ final class UploadController extends Controller
return response()->json([ return response()->json([
'artwork_id' => $artworkId, 'artwork_id' => $artworkId,
'status' => $status, 'status' => $status,
'preview_path' => $previewPath,
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('Upload finish failed', [ Log::error('Upload finish failed', [
@@ -476,6 +475,58 @@ final class UploadController extends Controller
{ {
$user = $request->user(); $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 { try {
$upload = $publishService->publish($id, $user); $upload = $publishService->publish($id, $user);
@@ -484,7 +535,6 @@ final class UploadController extends Controller
'upload_id' => (string) $upload->id, 'upload_id' => (string) $upload->id,
'status' => (string) $upload->status, 'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(), 'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) $upload->final_path,
], Response::HTTP_OK); ], Response::HTTP_OK);
} catch (UploadOwnershipException $e) { } catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN); return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);

View File

@@ -2,8 +2,8 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
use App\Services\ThumbnailService;
class ArtworkListResource extends JsonResource class ArtworkListResource extends JsonResource
{ {
@@ -48,6 +48,8 @@ class ArtworkListResource extends JsonResource
$categoryPath = $primaryCategory->full_slug_path ?? null; $categoryPath = $primaryCategory->full_slug_path ?? null;
} }
$slugVal = $get('slug'); $slugVal = $get('slug');
$hash = (string) ($get('hash') ?? '');
$thumbExt = (string) ($get('thumb_ext') ?? '');
$webUrl = $contentTypeSlug && $categoryPath && $slugVal $webUrl = $contentTypeSlug && $categoryPath && $slugVal
? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal ? '/' . strtolower($contentTypeSlug) . '/' . strtolower($categoryPath) . '/' . $slugVal
: null; : null;
@@ -60,7 +62,7 @@ class ArtworkListResource extends JsonResource
'width' => $get('width'), 'width' => $get('width'),
'height' => $get('height'), '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 () { 'author' => $this->whenLoaded('user', function () {
return [ return [
'name' => $this->user->name ?? null, 'name' => $this->user->name ?? null,

View File

@@ -2,7 +2,6 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
class ArtworkResource extends JsonResource class ArtworkResource extends JsonResource
@@ -32,6 +31,21 @@ class ArtworkResource extends JsonResource
} }
return null; 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 [ return [
'slug' => $get('slug'), 'slug' => $get('slug'),
'title' => $get('title'), 'title' => $get('title'),
@@ -39,10 +53,10 @@ class ArtworkResource extends JsonResource
'width' => $get('width'), 'width' => $get('width'),
'height' => $get('height'), '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' => [ 'file' => [
'name' => $get('file_name') ?? null, '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, 'size' => $get('file_size') ?? null,
'mime_type' => $get('mime_type') ?? null, 'mime_type' => $get('mime_type') ?? null,
], ],

View File

@@ -14,6 +14,7 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -166,7 +167,9 @@ final class AutoTagArtworkJob implements ShouldQueue
->timeout(max(1, $timeout)) ->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false) ->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, [ ->post($url, [
'url' => $imageUrl,
'image_url' => $imageUrl, 'image_url' => $imageUrl,
'limit' => 8,
'artwork_id' => $this->artworkId, 'artwork_id' => $this->artworkId,
'hash' => $this->hash, 'hash' => $this->hash,
]); ]);
@@ -182,6 +185,41 @@ final class AutoTagArtworkJob implements ShouldQueue
if (! $response->ok()) { if (! $response->ok()) {
Log::warning('CLIP analyze non-ok response', ['ref' => $ref, 'status' => $response->status(), 'body' => $this->safeBody($response->body())]); 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 []; return [];
} }
@@ -216,7 +254,9 @@ final class AutoTagArtworkJob implements ShouldQueue
->timeout(max(1, $timeout)) ->timeout(max(1, $timeout))
->retry(max(0, $retries), max(0, $delay), throw: false) ->retry(max(0, $retries), max(0, $delay), throw: false)
->post($url, [ ->post($url, [
'url' => $imageUrl,
'image_url' => $imageUrl, 'image_url' => $imageUrl,
'conf' => 0.25,
'artwork_id' => $this->artworkId, 'artwork_id' => $this->artworkId,
'hash' => $this->hash, 'hash' => $this->hash,
]); ]);

View File

@@ -22,7 +22,7 @@ final class ArtworkDraftService
'slug' => $slug, 'slug' => $slug,
'description' => $description, 'description' => $description,
'file_name' => 'pending', 'file_name' => 'pending',
'file_path' => 'pending', 'file_path' => '',
'file_size' => 0, 'file_size' => 0,
'mime_type' => 'application/octet-stream', 'mime_type' => 'application/octet-stream',
'width' => 1, 'width' => 1,

View File

@@ -8,6 +8,7 @@ use App\DTOs\Uploads\UploadSessionData;
use App\DTOs\Uploads\UploadInitResult; use App\DTOs\Uploads\UploadInitResult;
use App\DTOs\Uploads\UploadValidatedFile; use App\DTOs\Uploads\UploadValidatedFile;
use App\DTOs\Uploads\UploadScanResult; use App\DTOs\Uploads\UploadScanResult;
use App\Models\Artwork;
use App\Repositories\Uploads\ArtworkFileRepository; use App\Repositories\Uploads\ArtworkFileRepository;
use App\Repositories\Uploads\UploadSessionRepository; use App\Repositories\Uploads\UploadSessionRepository;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@@ -121,6 +122,22 @@ final class UploadPipelineService
$publicRelative[$variant] = $relativePath; $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->updateStatus($sessionId, UploadSessionStatus::PROCESSED);
$this->sessions->updateProgress($sessionId, 100); $this->sessions->updateProgress($sessionId, 100);
$this->audit->log($session->userId, 'upload_processed', $session->ip, [ $this->audit->log($session->userId, 'upload_processed', $session->ip, [

View File

@@ -565,6 +565,8 @@ subLoginMenu ul {
padding:0; padding:0;
height:50px; 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 { .nav>li.menu_notice>a {
@@ -583,7 +585,9 @@ subLoginMenu ul {
} }
.navbar-skinbase { .navbar-skinbase {
background:rgba(16, 25, 33, 0.9); background: rgba(16, 25, 33, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
border-bottom:solid 1px #000; border-bottom:solid 1px #000;
box-shadow:0 0 14px #333; box-shadow:0 0 14px #333;
z-index:1000; z-index:1000;
@@ -671,7 +675,9 @@ subLoginMenu ul {
} }
.navbar-skinbase .dropdown-menu { .navbar-skinbase .dropdown-menu {
background:rgba(16, 25, 33, 0.9); background: rgba(16, 25, 33, 0.6);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
border-bottom:solid 1px #000; border-bottom:solid 1px #000;
box-shadow:0 0 14px #333; box-shadow:0 0 14px #333;
color:#fff; color:#fff;

View File

@@ -7,13 +7,13 @@
} }
.form-input { .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 text-white placeholder-gray-500
focus:outline-none focus:ring-2 focus:ring-accent; focus:outline-none focus:ring-2 focus:ring-accent;
} }
.form-textarea { .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 text-white resize-none
focus:outline-none focus:ring-2 focus:ring-accent; focus:outline-none focus:ring-2 focus:ring-accent;
} }
@@ -22,7 +22,7 @@
@apply w-full text-sm text-soft @apply w-full text-sm text-soft
file:bg-panel file:border-0 file:px-4 file:py-2 file:bg-panel file:border-0 file:px-4 file:py-2
file:rounded-lg file:text-white file:rounded-lg file:text-white
hover:file:bg-nebula-600/40; hover:file:bg-nova-600/40;
} }
.btn-primary { .btn-primary {
@@ -31,8 +31,8 @@
} }
.btn-secondary { .btn-secondary {
@apply bg-nebula-500/30 text-white px-5 py-2 rounded-lg @apply bg-nova-500/30 text-white px-5 py-2 rounded-lg
hover:bg-nebula-500/50 transition; hover:bg-nova-500/50 transition;
} }
@layer components { @layer components {
@@ -44,7 +44,7 @@
input[type="password"], input[type="password"],
textarea, textarea,
select { 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; placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent;
} }

View File

@@ -5,7 +5,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
return ( return (
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4"> <nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto sm:gap-3"> <ol className="flex flex-nowrap items-center gap-3 overflow-x-auto sm:gap-4">
{steps.map((step, index) => { {steps.map((step, index) => {
const number = index + 1 const number = index + 1
const isActive = number === safeActive const isActive = number === safeActive
@@ -29,21 +29,21 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
: 'border-white/20 bg-white/5 text-white/80' : 'border-white/20 bg-white/5 text-white/80'
return ( return (
<li key={step.key} className="flex min-w-0 items-center gap-2"> <li key={step.key} className="flex-shrink-0 flex items-center gap-3">
<button <button
type="button" type="button"
onClick={() => canNavigate && onStepClick?.(number)} onClick={() => canNavigate && onStepClick?.(number)}
disabled={isLocked} disabled={isLocked}
aria-disabled={isLocked ? 'true' : 'false'} aria-disabled={isLocked ? 'true' : 'false'}
aria-current={isActive ? 'step' : undefined} aria-current={isActive ? 'step' : undefined}
className={`${baseBtn} ${stateClass}`} className={`${baseBtn} ${stateClass} flex-shrink-0`}
> >
<span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}> <span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}>
{isComplete ? '✓' : number} {isComplete ? '✓' : number}
</span> </span>
<span className="whitespace-nowrap">{step.label}</span> <span className="whitespace-nowrap pr-3">{step.label}</span>
</button> </button>
{index < steps.length - 1 && <span className="text-white/50"></span>} {index < steps.length - 1 && <span className="text-white/50 mx-1 select-none"></span>}
</li> </li>
) )
})} })}

View File

@@ -354,6 +354,17 @@ export default function UploadWizard({
rightsAccepted: false, rightsAccepted: false,
contentType: '', contentType: '',
}) })
const [visionSuggestedTags, setVisionSuggestedTags] = useState([])
const [visionDebug, setVisionDebug] = useState({
enabled: null,
queueConnection: '',
queuedJobs: 0,
failedJobs: 0,
triggered: false,
aiTagCount: 0,
totalTagCount: 0,
lastError: '',
})
const [isUploadLocked, setIsUploadLocked] = useState(false) const [isUploadLocked, setIsUploadLocked] = useState(false)
const [resolvedArtworkId, setResolvedArtworkId] = useState(() => { const [resolvedArtworkId, setResolvedArtworkId] = useState(() => {
const parsed = Number(initialDraftId) const parsed = Number(initialDraftId)
@@ -371,6 +382,7 @@ export default function UploadWizard({
const requestControllersRef = useRef(new Set()) const requestControllersRef = useRef(new Set())
const publishLockRef = useRef(false) const publishLockRef = useRef(false)
const hasAutoAdvancedRef = useRef(false) const hasAutoAdvancedRef = useRef(false)
const lastVisionFetchAtRef = useRef(0)
const effectiveChunkSize = useMemo(() => { const effectiveChunkSize = useMemo(() => {
const parsed = Number(chunkSize) const parsed = Number(chunkSize)
@@ -422,6 +434,32 @@ export default function UploadWizard({
}, [metadata, requiresSubCategory]) }, [metadata, requiresSubCategory])
const detailsValid = Object.keys(metadataErrors).length === 0 const detailsValid = Object.keys(metadataErrors).length === 0
const mergedSuggestedTags = useMemo(() => {
const normalized = new Map()
const addTag = (item) => {
if (!item) return
const key = String(item?.slug || item?.tag || item?.name || item).trim().toLowerCase()
if (!key) return
if (!normalized.has(key)) {
if (typeof item === 'string') {
normalized.set(key, item)
} else {
normalized.set(key, {
id: item.id ?? key,
name: item.name || item.tag || item.slug || key,
slug: item.slug || item.tag || key,
usage_count: Number(item.usage_count || 0),
is_ai: Boolean(item.is_ai || item.source === 'ai'),
source: item.source || (item.is_ai ? 'ai' : 'manual'),
})
}
}
}
;(Array.isArray(suggestedTags) ? suggestedTags : []).forEach(addTag)
;(Array.isArray(visionSuggestedTags) ? visionSuggestedTags : []).forEach(addTag)
return Array.from(normalized.values())
}, [suggestedTags, visionSuggestedTags])
const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1 const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1
const showProgress = machine.state !== machineStates.idle && machine.state !== machineStates.cancelled const showProgress = machine.state !== machineStates.idle && machine.state !== machineStates.cancelled
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
@@ -697,6 +735,68 @@ export default function UploadWizard({
} }
}, [machine.sessionId, machine.uploadToken, fetchProcessingStatus, registerController, unregisterController]) }, [machine.sessionId, machine.uploadToken, fetchProcessingStatus, registerController, unregisterController])
const fetchVisionSuggestedTags = useCallback(async () => {
if (!resolvedArtworkId || resolvedArtworkId <= 0) return
const now = Date.now()
if (now - lastVisionFetchAtRef.current < 3000) return
lastVisionFetchAtRef.current = now
try {
const response = await window.axios.get(`/api/artworks/${resolvedArtworkId}/tags`, {
params: { trigger: 1 },
})
const payload = response?.data || {}
if (payload?.vision_enabled === false) {
setVisionSuggestedTags([])
setVisionDebug((current) => ({
...current,
enabled: false,
lastError: '',
}))
return
}
const aiTags = Array.isArray(payload?.ai_tags) ? payload.ai_tags : []
setVisionSuggestedTags(aiTags)
const debug = payload?.debug || {}
setVisionDebug({
enabled: Boolean(payload?.vision_enabled),
queueConnection: String(debug?.queue_connection || ''),
queuedJobs: Number(debug?.queued_jobs || 0),
failedJobs: Number(debug?.failed_jobs || 0),
triggered: Boolean(debug?.triggered),
aiTagCount: Number(debug?.ai_tag_count || aiTags.length || 0),
totalTagCount: Number(debug?.total_tag_count || 0),
lastError: '',
})
if (typeof window !== 'undefined') {
window.console?.debug?.('[upload][vision-tags]', {
artworkId: resolvedArtworkId,
aiTags: aiTags.map((tag) => tag?.slug || tag?.name || tag),
debug,
})
}
} catch (error) {
if (error?.response?.status === 404 || error?.response?.status === 403) return
setVisionDebug((current) => ({
...current,
lastError: error?.response?.data?.message || error?.message || 'Vision tag fetch failed.',
}))
}
}, [resolvedArtworkId])
useEffect(() => {
if (!resolvedArtworkId || activeStep < 2) return
fetchVisionSuggestedTags()
const timer = window.setInterval(() => {
fetchVisionSuggestedTags()
}, 4000)
return () => window.clearInterval(timer)
}, [resolvedArtworkId, activeStep, fetchVisionSuggestedTags])
useEffect(() => { useEffect(() => {
if (machine.state !== machineStates.processing) { if (machine.state !== machineStates.processing) {
clearPolling() clearPolling()
@@ -745,12 +845,21 @@ export default function UploadWizard({
dispatchMachine({ type: 'PUBLISH_START' }) dispatchMachine({ type: 'PUBLISH_START' })
try { try {
const publishTargetId = machine.sessionId || initialDraftId || resolvedArtworkId const publishTargetId = resolvedArtworkId || initialDraftId || machine.sessionId
const publishPayload = {
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
}
if (!machine.sessionId) { if (!machine.sessionId) {
if (!publishTargetId) throw new Error('Missing publish id.') if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController() const publishController = registerController()
await window.axios.post(uploadEndpoints.publish(publishTargetId), {}, { signal: publishController.signal }) await window.axios.post(uploadEndpoints.publish(publishTargetId), publishPayload, { signal: publishController.signal })
unregisterController(publishController)
} else {
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
await window.axios.post(uploadEndpoints.publish(publishTargetId), publishPayload, { signal: publishController.signal })
unregisterController(publishController) unregisterController(publishController)
} }
@@ -764,7 +873,7 @@ export default function UploadWizard({
} finally { } finally {
publishLockRef.current = false publishLockRef.current = false
} }
}, [canPublish, machine.sessionId, initialDraftId, resolvedArtworkId, registerController, unregisterController]) }, [canPublish, machine.sessionId, initialDraftId, resolvedArtworkId, metadata.title, metadata.description, registerController, unregisterController])
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
clearPolling() clearPolling()
@@ -780,6 +889,7 @@ export default function UploadWizard({
rightsAccepted: false, rightsAccepted: false,
contentType: '', contentType: '',
}) })
setVisionSuggestedTags([])
setResolvedArtworkId(() => { setResolvedArtworkId(() => {
const parsed = Number(initialDraftId) const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
@@ -1059,13 +1169,26 @@ export default function UploadWizard({
<UploadSidebar <UploadSidebar
showHeader={false} showHeader={false}
metadata={metadata} metadata={metadata}
suggestedTags={suggestedTags} suggestedTags={mergedSuggestedTags}
errors={metadataErrors} errors={metadataErrors}
onChangeTitle={(value) => setMetadata((current) => ({ ...current, title: value }))} onChangeTitle={(value) => setMetadata((current) => ({ ...current, title: value }))}
onChangeTags={(value) => setMetadata((current) => ({ ...current, tags: value }))} onChangeTags={(value) => setMetadata((current) => ({ ...current, tags: value }))}
onChangeDescription={(value) => setMetadata((current) => ({ ...current, description: value }))} onChangeDescription={(value) => setMetadata((current) => ({ ...current, description: value }))}
onToggleRights={(value) => setMetadata((current) => ({ ...current, rightsAccepted: Boolean(value) }))} onToggleRights={(value) => setMetadata((current) => ({ ...current, rightsAccepted: Boolean(value) }))}
/> />
{resolvedArtworkId ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-white/70">
<div className="font-medium text-white/85">Vision debug</div>
<div className="mt-1">
enabled: {String(visionDebug.enabled)} · queue: {visionDebug.queueConnection || 'n/a'} · ai tags: {visionDebug.aiTagCount} · total tags: {visionDebug.totalTagCount}
</div>
<div className="mt-1">
queued jobs: {visionDebug.queuedJobs} · failed jobs: {visionDebug.failedJobs} · trigger sent: {String(visionDebug.triggered)}
</div>
{visionDebug.lastError ? <div className="mt-1 text-red-300">error: {visionDebug.lastError}</div> : null}
</div>
) : null}
</div> </div>
</div> </div>
) )

View File

@@ -3,14 +3,14 @@
* The Nova layout is styled primarily via Tailwind utilities from resources/css/app.css. * The Nova layout is styled primarily via Tailwind utilities from resources/css/app.css.
*/ */
/* Hero radial background moved from inline styles to a class to respect Nebula rules */ /* Hero radial background moved from inline styles to a class to respect Nova rules */
.nb-hero-radial { .nb-hero-radial {
background-image: radial-gradient(circle at 20% 10%, rgba(77,163,255,.25), transparent 35%), background-image: radial-gradient(circle at 20% 10%, rgba(77,163,255,.25), transparent 35%),
radial-gradient(circle at 70% 30%, rgba(255,196,77,.18), transparent 40%), radial-gradient(circle at 70% 30%, rgba(255,196,77,.18), transparent 40%),
radial-gradient(circle at 30% 80%, rgba(180,77,255,.16), transparent 45%); radial-gradient(circle at 30% 80%, rgba(180,77,255,.16), transparent 45%);
} }
/* Nebula design tokens and helper classes copied from nova.html preview /* Nova design tokens and helper classes copied from nova.html preview
These provide the same color tokens and small utilities used by the preview These provide the same color tokens and small utilities used by the preview
so `blank` renders consistently until Tailwind config is consolidated. */ so `blank` renders consistently until Tailwind config is consolidated. */
:root { :root {
@@ -23,16 +23,16 @@
--sb-muted: #a6a6b0; --sb-muted: #a6a6b0;
--sb-blue: #4da3ff; --sb-blue: #4da3ff;
/* Primary UI color tokens (Nebula palette) */ /* Primary UI color tokens (Nova palette) */
--nebula-blue: #09101acc; --nova-blue: #09101acc;
--deep-space: #0F1724; --deep-space: #0F1724;
--panel-dark: #151E2E; --panel-dark: #151E2E;
--soft-blue: #7A8CA5; --soft-blue: #7A8CA5;
--accent-orange: #E07A21; --accent-orange: #E07A21;
/* Toolbar color (Skinbase Nebula) */ /* Toolbar color (Skinbase Nova) */
--toolbar-bg: #0F1724; --toolbar-bg: #0F1724;
/* RGB variants for subtle overlays */ /* RGB variants for subtle overlays */
--nebula-blue-rgb: 100,111,131; --nova-blue-rgb: 100,111,131;
--deep-space-rgb: 15,23,36; --deep-space-rgb: 15,23,36;
--panel-dark-rgb: 21,30,46; --panel-dark-rgb: 21,30,46;
--toolbar-bg-rgb: 15,23,36; --toolbar-bg-rgb: 15,23,36;
@@ -58,7 +58,7 @@
/* Ensure header and dropdowns are not clipped and render above page content */ /* Ensure header and dropdowns are not clipped and render above page content */
header { header {
overflow: visible; overflow: visible;
/* Use the official toolbar background token from the Nebula spec */ /* Use the official toolbar background token from the Nova spec */
background-color: var(--toolbar-bg); background-color: var(--toolbar-bg);
/* subtle divider to separate toolbar from content */ /* subtle divider to separate toolbar from content */
border-bottom: 1px solid rgba(255,255,255,0.02); border-bottom: 1px solid rgba(255,255,255,0.02);
@@ -72,8 +72,8 @@ header {
border: 1px solid rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.03);
} }
/* Convenience helpers for the new nebula tokens */ /* Convenience helpers for the new nova tokens */
.bg-nebula { background-color: var(--nebula-blue) !important; } .bg-nova { background-color: var(--nova-blue) !important; -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(2px); }
.bg-deep { background-color: var(--deep-space) !important; } .bg-deep { background-color: var(--deep-space) !important; }
.bg-panel-dark { background-color: var(--panel-dark) !important; } .bg-panel-dark { background-color: var(--panel-dark) !important; }
.text-soft { color: var(--soft-blue) !important; } .text-soft { color: var(--soft-blue) !important; }

View File

@@ -34,7 +34,7 @@
<div class="flex min-h-[calc(100vh-64px)]"> <div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center"> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">

View File

@@ -6,7 +6,7 @@
<div class="mx-auto w-full"> <div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]"> <div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center"> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
@@ -69,7 +69,7 @@
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg"> <section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6"> <div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">Fantasy</div> <div class="text-lg font-semibold text-white/90">Fantasy</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">A small preview of the Nebula layout, server-rendered for SEO and progressive enhancement.</p> <p class="mt-2 text-sm leading-6 text-neutral-400">A small preview of the Nova layout, server-rendered for SEO and progressive enhancement.</p>
</div> </div>
</section> </section>
</div> </div>
@@ -115,20 +115,20 @@
</section> </section>
<!-- Nebula color scale examples --> <!-- Nova color scale examples -->
<section class="px-6 pb-10 md:px-10 mt-8"> <section class="px-6 pb-10 md:px-10 mt-8">
<h2 class="text-lg font-semibold mb-4">Nebula color scale</h2> <h2 class="text-lg font-semibold mb-4">Nova color scale</h2>
<div class="grid grid-cols-2 sm:grid-cols-5 md:grid-cols-10 gap-3"> <div class="grid grid-cols-2 sm:grid-cols-5 md:grid-cols-10 gap-3">
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-50 text-black">nebula-50</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-50 text-black">nova-50</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-100 text-black">nebula-100</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-100 text-black">nova-100</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-200 text-black">nebula-200</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-200 text-black">nova-200</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-300 text-black">nebula-300</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-300 text-black">nova-300</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-400 text-black">nebula-400</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-400 text-black">nova-400</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-500 text-white">nebula-500</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-500 text-white">nova-500</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-600 text-white">nebula-600</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-600 text-white">nova-600</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-700 text-white">nebula-700</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-700 text-white">nova-700</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-800 text-white">nebula-800</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-800 text-white">nova-800</div>
<div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nebula-900 text-white">nebula-900</div> <div class="h-20 rounded-md flex items-center justify-center text-sm font-medium border border-white/5 bg-nova-900 text-white">nova-900</div>
</div> </div>
</section> </section>

View File

@@ -14,8 +14,8 @@
<!-- Scripts --> <!-- Scripts -->
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
</head> </head>
<body class="font-sans antialiased"> <body class="font-sans antialiased bg-nova-800">
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen">
@include('layouts.navigation') @include('layouts.navigation')
<!-- Page Heading --> <!-- Page Heading -->

View File

@@ -15,12 +15,12 @@
@vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js']) @vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js'])
@stack('head') @stack('head')
</head> </head>
<body class="bg-nebula-900 text-white min-h-screen"> <body class="bg-nova-800 text-white min-h-screen flex flex-col">
<!-- React Topbar mount point --> <!-- React Topbar mount point -->
<div id="topbar-root"></div> <div id="topbar-root"></div>
@include('layouts.nova.toolbar') @include('layouts.nova.toolbar')
<main class="pt-16"> <main class="flex-1 pt-16">
@yield('content') @yield('content')
</main> </main>
@@ -35,7 +35,7 @@
@if($toastMessage) @if($toastMessage)
<div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak <div x-data="{show:true}" x-show="show" x-init="setTimeout(()=>show=false,4000)" x-cloak
class="fixed right-4 bottom-6 z-50"> class="fixed right-4 bottom-6 z-50">
<div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nebula-600 border {{ $toastBorder }}"> <div class="max-w-sm w-full rounded-lg shadow-lg overflow-hidden bg-nova-600 border {{ $toastBorder }}">
<div class="px-4 py-3 flex items-start gap-3"> <div class="px-4 py-3 flex items-start gap-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@if(session('error')) @if(session('error'))

View File

@@ -1,5 +1,5 @@
<!-- Footer --> <!-- Footer -->
<footer class="border-t border-neutral-800 bg-nebula"> <footer class="border-t border-neutral-800 bg-nova">
<div class="px-6 md:px-10 py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-2"> <div class="px-6 md:px-10 py-8 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div class="text-xl font-semibold tracking-wide flex items-center gap-1"> <div class="text-xl font-semibold tracking-wide flex items-center gap-1">
<img src="/gfx/skinbase_logo.png" alt="Skinbase" class="h-16 w-auto object-contain"> <img src="/gfx/skinbase_logo.png" alt="Skinbase" class="h-16 w-auto object-contain">

View File

@@ -1,4 +1,4 @@
<header class="fixed inset-x-0 top-0 z-50 h-16 bg-nebula border-b border-panel"> <header class="fixed inset-x-0 top-0 z-50 h-16 bg-nova border-b border-panel">
<div class="mx-auto w-full h-full px-4 flex items-center gap-3"> <div class="mx-auto w-full h-full px-4 flex items-center gap-3">
<!-- Mobile hamburger --> <!-- Mobile hamburger -->
<button id="btnSidebar" class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5"> <button id="btnSidebar" class="md:hidden inline-flex items-center justify-center w-10 h-10 rounded-lg hover:bg-white/5">

View File

@@ -13,7 +13,7 @@
<div class="flex min-h-[calc(100vh-64px)]"> <div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center"> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">

View File

@@ -13,7 +13,7 @@
<div class="flex min-h-[calc(100vh-64px)]"> <div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center"> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">

View File

@@ -21,7 +21,7 @@
<div class="mx-auto w-full"> <div class="mx-auto w-full">
<div class="flex min-h-[calc(100vh-64px)]"> <div class="flex min-h-[calc(100vh-64px)]">
<!-- SIDEBAR --> <!-- SIDEBAR -->
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nebula-900/60 backdrop-blur-sm"> <aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
<div class="p-4"> <div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4"> <button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center"> <span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
@@ -72,7 +72,7 @@
<!-- MAIN --> <!-- MAIN -->
<main class="flex-1"> <main class="flex-1">
<div class="nebula-gallery grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"> <div class="nova-gallery grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
@foreach($artworks as $art) @foreach($artworks as $art)
@include('legacy._artwork_card', ['art' => $art]) @include('legacy._artwork_card', ['art' => $art])
@endforeach @endforeach
@@ -90,30 +90,30 @@
@push('styles') @push('styles')
<style> <style>
/* Nebula-like gallery tweaks: fixed-height thumbnails, tighter spacing, refined typography */ /* Nova-like gallery tweaks: fixed-height thumbnails, tighter spacing, refined typography */
.nebula-gallery { .nova-gallery {
margin-top: 1.25rem; margin-top: 1.25rem;
} }
.nebula-gallery .artwork a { display: block; } .nova-gallery .artwork a { display: block; }
/* Ensure consistent gap and card sizing across breakpoints */ /* Ensure consistent gap and card sizing across breakpoints */
@media (min-width: 1024px) { @media (min-width: 1024px) {
.nebula-gallery { gap: 1rem; } .nova-gallery { gap: 1rem; }
} }
/* Typography refinements to match Nebula */ /* Typography refinements to match Nova */
.nebula-gallery .artwork h3 { font-size: 0.95rem; line-height: 1.15; } .nova-gallery .artwork h3 { font-size: 0.95rem; line-height: 1.15; }
.nebula-gallery .artwork .text-xs { font-size: 0.72rem; } .nova-gallery .artwork .text-xs { font-size: 0.72rem; }
/* Improve image loading artifact handling */ /* Improve image loading artifact handling */
.nebula-gallery img { background: linear-gradient(180deg,#0b0b0b,#0f0f10); } .nova-gallery img { background: linear-gradient(180deg,#0b0b0b,#0f0f10); }
/* Remove any default margins on article cards that can create vertical gaps */ /* Remove any default margins on article cards that can create vertical gaps */
.nebula-gallery .artwork { margin: 0; } .nova-gallery .artwork { margin: 0; }
/* Ensure grid items don't collapse when overlay hidden */ /* Ensure grid items don't collapse when overlay hidden */
.nebula-gallery .artwork a { min-height: 0; } .nova-gallery .artwork a { min-height: 0; }
</style> </style>
@endpush @endpush

View File

@@ -1,15 +1,8 @@
<!DOCTYPE html> @extends('layouts.nova')
<html lang="{{ app()->getLocale() }}">
<head>
<title>{{ $page_title ?? 'Upload Artwork' }}</title>
<meta charset="UTF-8" /> @push('head')
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}" /> <meta name="csrf-token" content="{{ csrf_token() }}" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
<link rel="shortcut icon" href="/favicon.ico">
<script> <script>
window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, { window.SKINBASE_FLAGS = Object.assign({}, window.SKINBASE_FLAGS || {}, {
uploads_v2: @json((bool) config('features.uploads_v2', false)), uploads_v2: @json((bool) config('features.uploads_v2', false)),
@@ -19,16 +12,28 @@
}); });
</script> </script>
@vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/entry-topbar.jsx','resources/js/upload.jsx']) @vite(['resources/js/entry-topbar.jsx','resources/js/upload.jsx'])
</head> <style>
<body class="bg-nebula-900 text-white min-h-screen"> /* Upload page spacing: extra top padding and bottom space so sticky action bar won't overlap content */
<div id="topbar-root"></div> body.page-upload main {
@include('layouts.nova.toolbar') padding-top: 6rem; /* slightly larger top padding on upload */
padding-bottom: 6.5rem; /* room for sticky action bar */
}
<main class="pt-16"> /* Ensure the footer is visually separated on short pages */
body.page-upload footer {
margin-top: 1rem;
}
</style>
<script>
// Mark document to apply upload-specific spacing
document.addEventListener('DOMContentLoaded', function () {
document.body.classList.add('page-upload')
})
</script>
@endpush
@section('content')
@inertia @inertia
</main> @endsection
@include('layouts.nova.footer')
</body>
</html>

View File

@@ -123,6 +123,7 @@ Route::middleware(['web', 'auth'])->prefix('tags')->name('api.tags.')->group(fun
}); });
Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.tags.')->group(function () { Route::middleware(['web', 'auth'])->prefix('artworks')->name('api.artworks.tags.')->group(function () {
Route::get('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'index'])->whereNumber('id')->name('index');
Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store'); Route::post('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'store'])->whereNumber('id')->name('store');
Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update'); Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update');
Route::delete('{id}/tags/{tag}', [\App\Http\Controllers\Api\ArtworkTagController::class, 'destroy'])->whereNumber('id')->name('destroy'); Route::delete('{id}/tags/{tag}', [\App\Http\Controllers\Api\ArtworkTagController::class, 'destroy'])->whereNumber('id')->name('destroy');

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
function printSection($title) {
echo "\n===== $title =====\n";
}
printSection('Recent queued jobs (jobs table)');
$jobs = DB::table('jobs')->where('payload', 'like', '%AutoTagArtworkJob%')->orderByDesc('id')->limit(10)->get();
if (count($jobs) === 0) {
echo "No queued AutoTagArtworkJob entries found.\n";
} else {
foreach ($jobs as $j) {
echo "--- job id: {$j->id} | queued_at: " . ($j->available_at ?? $j->created_at ?? '') . "\n";
$payload = isset($j->payload) ? $j->payload : '';
echo substr($payload, 0, 2000) . "\n\n";
}
}
printSection('Recent failed jobs (failed_jobs table)');
$failed = DB::table('failed_jobs')->where('payload', 'like', '%AutoTagArtworkJob%')->orderByDesc('id')->limit(10)->get();
if (count($failed) === 0) {
echo "No failed AutoTagArtworkJob entries found.\n";
} else {
foreach ($failed as $f) {
echo "--- failed id: {$f->id} | connection: {$f->connection} | failed_at: {$f->failed_at}\n";
$payload = isset($f->payload) ? $f->payload : '';
echo substr($payload, 0, 2000) . "\n\n";
}
}
printSection('Recent Laravel logs (last 200 lines)');
$logs = glob(__DIR__ . '/../storage/logs/*.log');
if ($logs === false || count($logs) === 0) {
echo "No log files found.\n";
} else {
usort($logs, function($a, $b){ return filemtime($b) <=> filemtime($a); });
$latest = $logs[0];
echo "Latest log: " . basename($latest) . "\n\n";
$lines = file($latest, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
echo "Unable to read log file.\n";
} else {
$tail = array_slice($lines, -200);
foreach ($tail as $line) {
echo $line . "\n";
}
}
}
echo "\nDone.\n";

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$options = getopt('', ['id::', 'limit::', 'publish']);
$id = isset($options['id']) ? (int) $options['id'] : null;
$limit = isset($options['limit']) ? max(1, (int) $options['limit']) : 25;
$publish = array_key_exists('publish', $options);
$brokenQuery = Artwork::query()->where(function ($query): void {
$query->where('file_path', 'pending')
->orWhereNull('hash')
->orWhereNull('file_ext')
->orWhereNull('thumb_ext')
->orWhere('file_name', 'pending')
->orWhere('file_size', 0)
->orWhere('width', '<=', 1)
->orWhere('height', '<=', 1);
});
if ($id && $id > 0) {
$brokenQuery->whereKey($id);
}
$rows = $brokenQuery->orderBy('id')->limit($limit)->get();
if ($rows->isEmpty()) {
fwrite(STDOUT, "No matching broken artworks found.\n");
exit(0);
}
$storageRoot = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
$fixed = 0;
$skipped = 0;
$makeUniqueSlug = static function (Artwork $artwork, string $title): string {
$base = Str::slug($title);
if ($base === '') {
$base = 'artwork-' . $artwork->id;
}
$slug = $base;
$suffix = 2;
while (Artwork::query()->where('slug', $slug)->where('id', '!=', $artwork->id)->exists()) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
};
foreach ($rows as $artwork) {
$files = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->get(['variant', 'path', 'mime', 'size'])
->keyBy('variant');
$orig = $files->get('orig');
if (! $orig || empty($orig->path)) {
fwrite(STDOUT, "[SKIP] {$artwork->id}: no orig variant in artwork_files\n");
$skipped++;
continue;
}
$origPath = (string) $orig->path;
$absolute = $storageRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\\\'], DIRECTORY_SEPARATOR, $origPath);
$hash = null;
$fileSize = (int) ($orig->size ?? 0);
$width = (int) $artwork->width;
$height = (int) $artwork->height;
if (is_file($absolute)) {
$hash = hash_file('sha256', $absolute) ?: null;
$actualSize = @filesize($absolute);
if (is_int($actualSize) && $actualSize > 0) {
$fileSize = $actualSize;
}
$dimensions = @getimagesize($absolute);
if (is_array($dimensions) && isset($dimensions[0], $dimensions[1])) {
$width = max(1, (int) $dimensions[0]);
$height = max(1, (int) $dimensions[1]);
}
}
$ext = strtolower((string) pathinfo($origPath, PATHINFO_EXTENSION));
if ($ext === '') {
$ext = 'webp';
}
$title = trim((string) ($artwork->title ?? ''));
if ($title === '') {
$title = 'Artwork ' . $artwork->id;
}
$slug = $makeUniqueSlug($artwork, $title);
$updates = [
'title' => $title,
'slug' => $slug,
'file_name' => basename($origPath),
'file_path' => $origPath,
'file_size' => max(1, $fileSize),
'mime_type' => (string) ($orig->mime ?: 'image/webp'),
'hash' => $hash ?: (string) ($artwork->hash ?? ''),
'file_ext' => $ext,
'thumb_ext' => $ext,
'width' => max(1, $width),
'height' => max(1, $height),
];
if ($publish) {
$updates['is_public'] = true;
$updates['is_approved'] = true;
$updates['published_at'] = $artwork->published_at ?: now();
}
if (empty($updates['hash'])) {
fwrite(STDOUT, "[SKIP] {$artwork->id}: hash could not be recovered from file\n");
$skipped++;
continue;
}
Artwork::query()->whereKey($artwork->id)->update($updates);
fwrite(STDOUT, "[FIXED] {$artwork->id}: {$updates['file_path']} | hash=" . substr((string) $updates['hash'], 0, 12) . "...\n");
$fixed++;
}
fwrite(STDOUT, "Done. fixed={$fixed} skipped={$skipped}\n");
exit(0);

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use Illuminate\Support\Carbon;
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$options = getopt('', ['id::', 'limit::']);
$id = isset($options['id']) ? (int) $options['id'] : null;
$limit = isset($options['limit']) ? max(1, (int) $options['limit']) : 5;
$query = Artwork::query()->orderByDesc('id');
if ($id && $id > 0) {
$query->whereKey($id);
}
$rows = $query->limit($limit)->get();
if ($rows->isEmpty()) {
fwrite(STDOUT, "No artwork rows found.\n");
exit(1);
}
$requiredFields = [
'title',
'slug',
'file_name',
'file_path',
'hash',
'file_ext',
'thumb_ext',
'mime_type',
'file_size',
'published_at',
];
$hasFailure = false;
foreach ($rows as $artwork) {
$missing = [];
foreach ($requiredFields as $field) {
$value = $artwork->{$field};
if ($value === null || $value === '' || $value === 'pending') {
$missing[] = $field;
}
}
if (! (bool) $artwork->is_public) {
$missing[] = 'is_public';
}
if (! (bool) $artwork->is_approved) {
$missing[] = 'is_approved';
}
if ((int) $artwork->width <= 1) {
$missing[] = 'width';
}
if ((int) $artwork->height <= 1) {
$missing[] = 'height';
}
$publishedAt = $artwork->published_at instanceof Carbon
? $artwork->published_at->toDateTimeString()
: (string) $artwork->published_at;
fwrite(STDOUT, str_repeat('-', 72) . "\n");
fwrite(STDOUT, "artwork_id: {$artwork->id}\n");
fwrite(STDOUT, "title : " . (string) $artwork->title . "\n");
fwrite(STDOUT, "slug : " . (string) $artwork->slug . "\n");
fwrite(STDOUT, "file_path : " . (string) $artwork->file_path . "\n");
fwrite(STDOUT, "hash : " . (string) $artwork->hash . "\n");
fwrite(STDOUT, "file_ext : " . (string) $artwork->file_ext . " | thumb_ext: " . (string) $artwork->thumb_ext . "\n");
fwrite(STDOUT, "visible : is_public=" . ((int) (bool) $artwork->is_public) . " is_approved=" . ((int) (bool) $artwork->is_approved) . "\n");
fwrite(STDOUT, "published : " . ($publishedAt !== '' ? $publishedAt : 'NULL') . "\n");
if ($missing !== []) {
$hasFailure = true;
fwrite(STDOUT, "status : FAIL (missing/invalid: " . implode(', ', array_unique($missing)) . ")\n");
} else {
fwrite(STDOUT, "status : OK\n");
}
}
fwrite(STDOUT, str_repeat('-', 72) . "\n");
if ($hasFailure) {
fwrite(STDOUT, "Result: FAIL\n");
exit(2);
}
fwrite(STDOUT, "Result: OK\n");
exit(0);

View File

@@ -12,7 +12,7 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
nebula: { nova: {
50: '#EAF0F7', // almost white blue 50: '#EAF0F7', // almost white blue
100: '#D6E0EE', 100: '#D6E0EE',
200: '#B3C3DD', 200: '#B3C3DD',