Restore toolbar background to bg-nebula; add toolbar backdrop blur
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -564,7 +564,9 @@ subLoginMenu ul {
|
|||||||
margin:0;
|
margin:0;
|
||||||
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,10 +585,12 @@ subLoginMenu ul {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar-skinbase {
|
.navbar-skinbase {
|
||||||
background:rgba(16, 25, 33, 0.9);
|
background: rgba(16, 25, 33, 0.6);
|
||||||
border-bottom:solid 1px #000;
|
-webkit-backdrop-filter: blur(6px);
|
||||||
box-shadow:0 0 14px #333;
|
backdrop-filter: blur(6px);
|
||||||
z-index:1000;
|
border-bottom:solid 1px #000;
|
||||||
|
box-shadow:0 0 14px #333;
|
||||||
|
z-index:1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown:hover .dropdown-menu {
|
.dropdown:hover .dropdown-menu {
|
||||||
@@ -671,10 +675,12 @@ subLoginMenu ul {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar-skinbase .dropdown-menu {
|
.navbar-skinbase .dropdown-menu {
|
||||||
background:rgba(16, 25, 33, 0.9);
|
background: rgba(16, 25, 33, 0.6);
|
||||||
border-bottom:solid 1px #000;
|
-webkit-backdrop-filter: blur(6px);
|
||||||
box-shadow:0 0 14px #333;
|
backdrop-filter: blur(6px);
|
||||||
color:#fff;
|
border-bottom:solid 1px #000;
|
||||||
|
box-shadow:0 0 14px #333;
|
||||||
|
color:#fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
@inertia
|
body.page-upload footer {
|
||||||
</main>
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@include('layouts.nova.footer')
|
<script>
|
||||||
</body>
|
// Mark document to apply upload-specific spacing
|
||||||
</html>
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.body.classList.add('page-upload')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@inertia
|
||||||
|
@endsection
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
58
scripts/inspect_autotag_jobs.php
Normal file
58
scripts/inspect_autotag_jobs.php
Normal 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";
|
||||||
138
scripts/repair_broken_artworks.php
Normal file
138
scripts/repair_broken_artworks.php
Normal 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);
|
||||||
98
scripts/verify_upload_publish.php
Normal file
98
scripts/verify_upload_publish.php
Normal 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);
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user