Profile: store covers in object storage (WebP); add covers config; remember artworks categories content-type preference

This commit is contained in:
2026-03-29 09:22:36 +02:00
parent cab4fbd83e
commit 1da7d3bf88
27 changed files with 703 additions and 448 deletions

View File

@@ -32,7 +32,7 @@ class DashboardGalleryController extends Controller
$artworks->getCollection()->map(fn (Artwork $artwork) => $this->presentArtwork($artwork)) $artworks->getCollection()->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
); );
$mainCategories = ContentType::orderBy('id') $mainCategories = ContentType::ordered()
->get(['name', 'slug']) ->get(['name', 'slug'])
->map(function (ContentType $type) { ->map(function (ContentType $type) {
return (object) [ return (object) [

View File

@@ -57,7 +57,7 @@ class BrowseController extends Controller
// Shape data for the legacy Blade while using authoritative tables only. // Shape data for the legacy Blade while using authoritative tables only.
$artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork)); $artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork));
$rootCategories = ContentType::orderBy('id') $rootCategories = ContentType::ordered()
->get(['name', 'slug']) ->get(['name', 'slug'])
->map(fn (ContentType $type) => (object) [ ->map(fn (ContentType $type) => (object) [
'name' => $type->name, 'name' => $type->name,

View File

@@ -164,7 +164,7 @@ final class StudioController extends Controller
private function getCategories(): array private function getCategories(): array
{ {
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [ return [
'id' => $ct->id, 'id' => $ct->id,
'name' => $ct->name, 'name' => $ct->name,

View File

@@ -7,11 +7,9 @@ use App\Support\CoverUrl;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver; use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver; use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager; use Intervention\Image\ImageManager;
use RuntimeException; use RuntimeException;
@@ -142,9 +140,9 @@ class ProfileCoverController extends Controller
]); ]);
} }
private function storageRoot(): string private function coverDiskName(): string
{ {
return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR); return (string) config('covers.disk', 's3');
} }
private function coverDirectory(string $hash): string private function coverDirectory(string $hash): string
@@ -152,15 +150,12 @@ class ProfileCoverController extends Controller
$p1 = substr($hash, 0, 2); $p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2); $p2 = substr($hash, 2, 2);
return $this->storageRoot() return 'covers/' . $p1 . '/' . $p2;
. DIRECTORY_SEPARATOR . 'covers'
. DIRECTORY_SEPARATOR . $p1
. DIRECTORY_SEPARATOR . $p2;
} }
private function coverPath(string $hash, string $ext): string private function coverPath(string $hash, string $ext): string
{ {
return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext; return $this->coverDirectory($hash) . '/' . $hash . '.' . $ext;
} }
/** /**
@@ -169,6 +164,7 @@ class ProfileCoverController extends Controller
private function storeCoverFile(UploadedFile $file): array private function storeCoverFile(UploadedFile $file): array
{ {
$this->assertImageManager(); $this->assertImageManager();
$this->assertStorageIsAllowed();
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname()); $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) { if ($uploadPath === '' || ! is_readable($uploadPath)) {
@@ -201,18 +197,26 @@ class ProfileCoverController extends Controller
)); ));
} }
$ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp');
$image = $this->manager->read($raw); $image = $this->manager->read($raw);
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center'); $processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
$ext = 'webp';
$encoded = $this->encodeByExtension($processed, $ext); $encoded = $this->encodeByExtension($processed, $ext);
$hash = hash('sha256', $encoded); $hash = hash('sha256', $encoded);
$dir = $this->coverDirectory($hash); $disk = Storage::disk($this->coverDiskName());
if (! File::exists($dir)) { $written = $disk->put($this->coverPath($hash, $ext), $encoded, [
File::makeDirectory($dir, 0755, true); 'visibility' => 'public',
} 'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => match ($ext) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
},
]);
File::put($this->coverPath($hash, $ext), $encoded); if ($written !== true) {
throw new RuntimeException('Unable to store cover image in object storage.');
}
return ['hash' => $hash, 'ext' => $ext]; return ['hash' => $hash, 'ext' => $ext];
} }
@@ -220,8 +224,6 @@ class ProfileCoverController extends Controller
private function encodeByExtension($image, string $ext): string private function encodeByExtension($image, string $ext): string
{ {
return match ($ext) { return match ($ext) {
'jpg' => (string) $image->encode(new JpegEncoder(85)),
'png' => (string) $image->encode(new PngEncoder()),
default => (string) $image->encode(new WebpEncoder(85)), default => (string) $image->encode(new WebpEncoder(85)),
}; };
} }
@@ -235,10 +237,7 @@ class ProfileCoverController extends Controller
return; return;
} }
$path = $this->coverPath($trimHash, $trimExt); Storage::disk($this->coverDiskName())->delete($this->coverPath($trimHash, $trimExt));
if (is_file($path)) {
@unlink($path);
}
} }
private function assertImageManager(): void private function assertImageManager(): void
@@ -249,4 +248,16 @@ class ProfileCoverController extends Controller
throw new RuntimeException('Image processing is not available on this environment.'); throw new RuntimeException('Image processing is not available on this environment.');
} }
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->coverDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production cover storage must use object storage, not local/public disks.');
}
}
} }

View File

@@ -10,7 +10,7 @@ class BrowseCategoriesController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get(); $contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->ordered()->get();
$categoriesByType = []; $categoriesByType = [];
$categories = collect(); $categories = collect();

View File

@@ -17,7 +17,7 @@ use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller class BrowseGalleryController extends \App\Http\Controllers\Controller
{ {
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other']; private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other', 'digital-art'];
/** /**
* Meilisearch sort-field arrays per sort alias. * Meilisearch sort-field arrays per sort alias.
@@ -108,7 +108,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort_options' => self::SORT_OPTIONS, 'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Browse Artworks', 'hero_title' => 'Browse Artworks',
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.', 'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
'breadcrumbs' => collect(), 'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/browse']]),
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase', 'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.", 'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.",
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo', 'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
@@ -164,7 +164,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort_options' => self::SORT_OPTIONS, 'sort_options' => self::SORT_OPTIONS,
'hero_title' => $contentType->name, 'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'), 'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]), 'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]),
'page_title' => $contentType->name . ' Skinbase Nova', 'page_title' => $contentType->name . ' Skinbase Nova',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
@@ -204,8 +207,17 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$subcategories = $rootCategories; $subcategories = $rootCategories;
} }
$breadcrumbs = collect($category->breadcrumbs) $breadcrumbs = collect(array_merge([
->map(function (Category $crumb) { (object) [
'name' => 'Explore',
'url' => '/browse',
],
(object) [
'name' => $contentType->name,
'url' => '/' . $contentSlug,
],
], $category->breadcrumbs))
->map(function ($crumb) {
return (object) [ return (object) [
'name' => $crumb->name, 'name' => $crumb->name,
'url' => $crumb->url, 'url' => $crumb->url,
@@ -344,7 +356,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private function mainCategories(): Collection private function mainCategories(): Collection
{ {
return ContentType::orderBy('id') return ContentType::ordered()
->whereIn('slug', self::CONTENT_TYPE_SLUGS)
->get(['name', 'slug']) ->get(['name', 'slug'])
->map(function (ContentType $type) { ->map(function (ContentType $type) {
return (object) [ return (object) [

View File

@@ -225,7 +225,7 @@ final class ExploreController extends Controller
private function mainCategories(): Collection private function mainCategories(): Collection
{ {
$categories = ContentType::orderBy('id') $categories = ContentType::ordered()
->get(['name', 'slug']) ->get(['name', 'slug'])
->map(fn ($ct) => (object) [ ->map(fn ($ct) => (object) [
'name' => $ct->name, 'name' => $ct->name,

View File

@@ -24,7 +24,7 @@ class SectionsController extends Controller
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name'); ->orderBy('name');
}, },
])->orderBy('id')->get(); ])->ordered()->get();
// Total artwork counts per content type via a single aggregation query // Total artwork counts per content type via a single aggregation query
$artworkCountsByType = DB::table('artworks') $artworkCountsByType = DB::table('artworks')

View File

@@ -73,7 +73,7 @@ final class TagController extends Controller
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories'])); $artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: main content type links (same as browse gallery) // Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug']) $mainCategories = ContentType::ordered()->get(['name', 'slug'])
->map(fn ($type) => (object) [ ->map(fn ($type) => (object) [
'id' => $type->id, 'id' => $type->id,
'name' => $type->name, 'name' => $type->name,

View File

@@ -10,7 +10,16 @@ use App\Models\Artwork;
class ContentType extends Model class ContentType extends Model
{ {
protected $fillable = ['name','slug','description']; protected $fillable = ['name','slug','description','order'];
protected $casts = [
'order' => 'integer',
];
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
{
return $query->orderBy('order')->orderBy('name')->orderBy('id');
}
public function categories(): HasMany public function categories(): HasMany
{ {

View File

@@ -20,7 +20,7 @@ final class StudioAiCategoryMapper
$tokens = $this->tokenize($signals); $tokens = $this->tokenize($signals);
$haystack = ' ' . implode(' ', $tokens) . ' '; $haystack = ' ' . implode(' ', $tokens) . ' ';
$contentTypes = ContentType::query()->with(['rootCategories.children'])->get(); $contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get();
$contentTypeScores = $contentTypes $contentTypeScores = $contentTypes
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack)) ->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
->filter(fn (array $row): bool => $row['score'] > 0) ->filter(fn (array $row): bool => $row['score'] > 0)

5
config/covers.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'disk' => env('COVER_DISK', env('AVATAR_DISK', 's3')),
];

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('content_types', function (Blueprint $table): void {
$table->unsignedInteger('order')->default(0)->after('description');
$table->index('order');
});
$ids = DB::table('content_types')
->orderBy('id')
->pluck('id');
foreach ($ids as $index => $id) {
DB::table('content_types')
->where('id', $id)
->update(['order' => $index + 1]);
}
}
public function down(): void
{
Schema::table('content_types', function (Blueprint $table): void {
$table->dropIndex(['order']);
$table->dropColumn('order');
});
}
};

View File

@@ -53,6 +53,21 @@
input[type="file"] { input[type="file"] {
@apply bg-transparent text-soft p-0; @apply bg-transparent text-soft p-0;
} }
.auth-card {
max-width: 720px;
margin-left: auto;
margin-right: auto;
}
.auth-card h1 {
font-size: 1.25rem;
line-height: 1.2;
}
.auth-card p {
color: rgba(203,213,225,0.9);
}
} }
@@ -61,9 +76,88 @@
box-sizing: border-box; box-sizing: border-box;
border: 0 solid transparent; border: 0 solid transparent;
} }
h1, h2, h3, h4, h5, h6 {
color: #ffffff;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
h1 {
font-size: 2.25rem;
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.02em;
}
h2 {
font-size: 1.5rem;
line-height: 1.15;
font-weight: 700;
letter-spacing: -0.01em;
}
h3 {
font-size: 1.125rem;
line-height: 1.2;
font-weight: 600;
}
h4 {
font-size: 1rem;
line-height: 1.25;
font-weight: 600;
}
h5 {
font-size: 0.95rem;
line-height: 1.25;
font-weight: 600;
}
h6 {
font-size: 0.85rem;
line-height: 1.3;
font-weight: 600;
text-transform: uppercase;
opacity: 0.85;
}
.prose h1 {
font-size: 2.25rem;
}
.prose h2 {
font-size: 1.5rem;
}
.prose h3 {
font-size: 1.125rem;
}
.prose h4,
.prose h5,
.prose h6 {
font-weight: 600;
}
[x-cloak] {
display: none !important;
}
} }
@layer utilities { @layer utilities {
.nova-card-enter {
opacity: 0;
transform: translateY(10px) scale(0.995);
}
.nova-card-enter.nova-card-enter-active {
transition: transform 380ms cubic-bezier(.2,.9,.2,1), opacity 380ms ease-out;
opacity: 1;
transform: none;
}
.nova-scrollbar { .nova-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.14) transparent; scrollbar-color: rgba(255,255,255,0.14) transparent;
@@ -171,6 +265,106 @@
.messages-page *::-webkit-scrollbar-corner { .messages-page *::-webkit-scrollbar-corner {
background: transparent; background: transparent;
} }
/* Gallery page helpers */
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
.nb-hero-gradient {
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
}
.gallery-rank-tab {
-webkit-tap-highlight-color: transparent;
}
.gallery-rank-tab .nb-tab-indicator {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 200ms ease;
}
.nb-scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.nb-scrollbar-none::-webkit-scrollbar {
display: none;
}
[data-react-masonry-gallery] {
animation: nb-gallery-fade-in 300ms ease-out both;
}
.nb-filter-choice {
display: inline-flex;
cursor: pointer;
}
.nb-filter-choice--block {
display: flex;
width: 100%;
}
.nb-filter-choice-label {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
color: rgba(214,224,238,0.8);
font-size: 0.8125rem;
font-weight: 500;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
.nb-filter-choice--block .nb-filter-choice-label {
border-radius: 0.6rem;
width: 100%;
}
.nb-filter-choice input:checked ~ .nb-filter-choice-label {
background: #E07A21;
border-color: #E07A21;
color: #fff;
box-shadow: 0 1px 8px rgba(224,122,33,0.35);
}
.nb-filter-choice input:focus-visible ~ .nb-filter-choice-label {
outline: 2px solid rgba(224,122,33,0.6);
outline-offset: 2px;
}
.nb-filter-input {
appearance: none;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.5rem;
color: rgba(255,255,255,0.85);
font-size: 0.8125rem;
padding: 0.425rem 0.75rem;
transition: border-color 150ms ease;
color-scheme: dark;
}
.nb-filter-input:focus {
outline: none;
border-color: rgba(224,122,33,0.6);
box-shadow: 0 0 0 3px rgba(224,122,33,0.15);
}
@keyframes nb-hero-shimmer {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
@keyframes nb-gallery-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
} }
/* ─── TipTap rich text editor ─── */ /* ─── TipTap rich text editor ─── */

View File

@@ -6,6 +6,7 @@ import TextInput from '../../components/ui/TextInput'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
import Modal from '../../components/ui/Modal' import Modal from '../../components/ui/Modal'
import FormField from '../../components/ui/FormField' import FormField from '../../components/ui/FormField'
import Toggle from '../../components/ui/Toggle'
import TagPicker from '../../components/tags/TagPicker' import TagPicker from '../../components/tags/TagPicker'
import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker' import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker'

View File

@@ -609,11 +609,11 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) {
if (uploadsV2Enabled) { if (uploadsV2Enabled) {
return ( return (
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100"> <section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
<div className="relative isolate overflow-hidden"> <div className="relative isolate">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" /> <div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8"> <div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{/* ── Wizard ─────────────────────────────────────────────────────── */} {/* ── Wizard ─────────────────────────────────────────────────────── */}
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]"> <div className="rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8"> <div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<UploadWizard <UploadWizard
initialDraftId={draftId ?? null} initialDraftId={draftId ?? null}

View File

@@ -50,6 +50,7 @@ export default function PublishPanel({
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone, timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
visibility = 'public', // 'public' | 'unlisted' | 'private' visibility = 'public', // 'public' | 'unlisted' | 'private'
showRightsConfirmation = true, showRightsConfirmation = true,
showVisibility = false,
onPublishModeChange, onPublishModeChange,
onScheduleAt, onScheduleAt,
onVisibilityChange, onVisibilityChange,
@@ -59,6 +60,7 @@ export default function PublishPanel({
onCancel, onCancel,
// Navigation helpers (for checklist quick-links) // Navigation helpers (for checklist quick-links)
onGoToStep, onGoToStep,
allRootCategoryOptions = [],
}) { }) {
const pill = STATUS_PILL[machineState] ?? null const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive) const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
@@ -67,7 +69,13 @@ export default function PublishPanel({
const title = String(metadata.title || '').trim() const title = String(metadata.title || '').trim()
const hasTitle = Boolean(title) const hasTitle = Boolean(title)
const hasCategory = Boolean(metadata.rootCategoryId) const selectedRoot = allRootCategoryOptions.find(
(item) => String(item.id) === String(metadata.rootCategoryId || '')
) ?? null
const requiresSubCategory = Boolean(selectedRoot?.children?.length)
const hasCompleteCategory = Boolean(
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
)
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0 const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
const hasRights = Boolean(metadata.rightsAccepted) const hasRights = Boolean(metadata.rightsAccepted)
const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0 const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0
@@ -75,7 +83,7 @@ export default function PublishPanel({
const checklist = [ const checklist = [
{ label: 'File uploaded & processed', ok: uploadReady }, { label: 'File uploaded & processed', ok: uploadReady },
{ label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) }, { label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) },
{ label: 'Category', ok: hasCategory, onClick: () => onGoToStep?.(2) }, { label: 'Category', ok: hasCompleteCategory, onClick: () => onGoToStep?.(2) },
{ label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) }, { label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) },
...( isArchiveRequiresScreenshot ...( isArchiveRequiresScreenshot
? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }] ? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }]
@@ -162,7 +170,8 @@ export default function PublishPanel({
<ReadinessChecklist items={checklist} /> <ReadinessChecklist items={checklist} />
</div> </div>
{/* Visibility */} {/* Visibility (only when showVisibility=true) */}
{showVisibility && (
<div> <div>
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility"> <label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
Visibility Visibility
@@ -198,9 +207,10 @@ export default function PublishPanel({
})} })}
</div> </div>
</div> </div>
)}
{/* Schedule picker only shows when upload is ready */} {/* Schedule picker only shows when enabled for this panel */}
{uploadReady && machineState !== 'complete' && ( {showVisibility && uploadReady && machineState !== 'complete' && (
<SchedulePublishPicker <SchedulePublishPicker
mode={publishMode} mode={publishMode}
scheduledAt={scheduledAt} scheduledAt={scheduledAt}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import TagPicker from '../tags/TagPicker' import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox' import Checkbox from '../../Components/ui/Checkbox'
import RichTextEditor from '../forum/RichTextEditor' import RichTextEditor from '../forum/RichTextEditor'
import SchedulePublishPicker from './SchedulePublishPicker'
export default function UploadSidebar({ export default function UploadSidebar({
title = 'Artwork details', title = 'Artwork details',
@@ -10,6 +11,11 @@ export default function UploadSidebar({
metadata, metadata,
suggestedTags = [], suggestedTags = [],
errors = {}, errors = {},
publishMode,
scheduledAt,
timezone,
onPublishModeChange,
onScheduleAt,
onChangeTitle, onChangeTitle,
onChangeTags, onChangeTags,
onChangeDescription, onChangeDescription,
@@ -17,7 +23,7 @@ export default function UploadSidebar({
onToggleRights, onToggleRights,
}) { }) {
return ( return (
<aside className="rounded-[28px] border border-white/8 bg-gradient-to-br from-slate-900/55 to-slate-900/35 p-6 shadow-[0_18px_60px_rgba(0,0,0,0.22)] sm:p-7"> <div className="space-y-5">
{showHeader && ( {showHeader && (
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4"> <div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
<h3 className="text-lg font-semibold text-white">{title}</h3> <h3 className="text-lg font-semibold text-white">{title}</h3>
@@ -77,6 +83,24 @@ export default function UploadSidebar({
/> />
</section> </section>
{typeof publishMode === 'string' && typeof onPublishModeChange === 'function' && (
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Publish settings</h4>
<p className="mt-1 text-xs text-white/60">Choose whether this artwork should publish immediately or on a schedule.</p>
</div>
<SchedulePublishPicker
mode={publishMode}
scheduledAt={scheduledAt}
timezone={timezone}
onModeChange={onPublishModeChange}
onScheduleAt={onScheduleAt}
disabled={false}
/>
</section>
)}
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5"> <section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox <Checkbox
id="upload-sidebar-mature" id="upload-sidebar-mature"
@@ -103,6 +127,6 @@ export default function UploadSidebar({
/> />
</section> </section>
</div> </div>
</aside> </div>
) )
} }

View File

@@ -240,11 +240,22 @@ export default function UploadWizard({
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state) const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state) const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
const hasTitle = Boolean(String(metadata.title || '').trim())
const hasCompleteCategory = Boolean(
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
)
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
const hasRequiredScreenshot = !isArchive || screenshots.length > 0
const canPublish = useMemo(() => ( const canPublish = useMemo(() => (
uploadReady && uploadReady &&
hasTitle &&
hasCompleteCategory &&
hasTag &&
hasRequiredScreenshot &&
metadata.rightsAccepted && metadata.rightsAccepted &&
machine.state !== machineStates.publishing machine.state !== machineStates.publishing
), [uploadReady, metadata.rightsAccepted, machine.state]) ), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
const canScheduleSubmit = useMemo(() => { const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false if (!canPublish) return false
@@ -424,6 +435,11 @@ export default function UploadWizard({
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })} onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })} onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
suggestedTags={mergedSuggestedTags} suggestedTags={mergedSuggestedTags}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onChangeTitle={(value) => setMeta({ title: value })} onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })} onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })} onChangeDescription={(value) => setMeta({ description: value })}
@@ -448,6 +464,7 @@ export default function UploadWizard({
scheduledAt={scheduledAt} scheduledAt={scheduledAt}
timezone={userTimezone} timezone={userTimezone}
visibility={visibility} visibility={visibility}
onVisibilityChange={setVisibility}
allRootCategoryOptions={allRootCategoryOptions} allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree} filteredCategoryTree={filteredCategoryTree}
/> />
@@ -601,6 +618,7 @@ export default function UploadWizard({
timezone={userTimezone} timezone={userTimezone}
visibility={visibility} visibility={visibility}
showRightsConfirmation={activeStep === 3} showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode} onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt} onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility} onVisibilityChange={setVisibility}
@@ -608,6 +626,7 @@ export default function UploadWizard({
onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })} onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onCancel={handleCancel} onCancel={handleCancel}
onGoToStep={goToStep} onGoToStep={goToStep}
allRootCategoryOptions={allRootCategoryOptions}
/> />
</div> </div>
)} )}
@@ -628,7 +647,14 @@ export default function UploadWizard({
Publish Publish
{!canPublish && ( {!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]"> <span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[...(!uploadReady ? [1] : []), ...(!metadata.title ? [1] : []), ...(!metadata.rightsAccepted ? [1] : [])].length} {[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [1]),
...(metadata.rightsAccepted ? [] : [1]),
].length}
</span> </span>
)} )}
</button> </button>
@@ -674,6 +700,7 @@ export default function UploadWizard({
timezone={userTimezone} timezone={userTimezone}
visibility={visibility} visibility={visibility}
showRightsConfirmation={activeStep === 3} showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode} onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt} onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility} onVisibilityChange={setVisibility}
@@ -690,6 +717,7 @@ export default function UploadWizard({
setShowMobilePublishPanel(false) setShowMobilePublishPanel(false)
goToStep(s) goToStep(s)
}} }}
allRootCategoryOptions={allRootCategoryOptions}
/> />
</motion.div> </motion.div>
</> </>

View File

@@ -29,6 +29,11 @@ export default function Step2Details({
onSubCategoryChange, onSubCategoryChange,
// Sidebar (title / tags / description / rights) // Sidebar (title / tags / description / rights)
suggestedTags, suggestedTags,
publishMode,
scheduledAt,
timezone,
onPublishModeChange,
onScheduleAt,
onChangeTitle, onChangeTitle,
onChangeTags, onChangeTags,
onChangeDescription, onChangeDescription,
@@ -164,17 +169,21 @@ export default function Step2Details({
</div> </div>
</div> </div>
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(14,165,233,0.08),_rgba(15,23,36,0.92)_52%)] p-5 sm:p-6"> {/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-2"> <section className="rounded-2xl border border-white/10 bg-[radial-gradient(ellipse_at_top_left,_rgba(14,165,233,0.07),_transparent_45%),radial-gradient(ellipse_at_bottom_right,_rgba(168,85,247,0.07),_transparent_45%)] p-5 sm:p-6">
{/* Section header */}
<div className="mb-5 flex flex-wrap items-start justify-between gap-2">
<div> <div>
<h3 className="text-sm font-semibold text-white">Content type</h3> <h3 className="text-sm font-semibold text-white">Content type &amp; category</h3>
<p className="mt-1 text-xs text-white/55">Choose the main content family first.</p> <p className="mt-1 text-xs text-white/55">Choose the content family, then narrow down to a category and subcategory.</p>
</div> </div>
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300"> <span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">Step 2</span>
Step 2a
</span>
</div> </div>
{/* ── Content type ── */}
<div>
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Content type</p>
{contentTypeOptions.length === 0 && ( {contentTypeOptions.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400"> <div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
No content types are available right now. No content types are available right now.
@@ -184,13 +193,18 @@ export default function Step2Details({
{selectedContentType && !isContentTypeChooserOpen && ( {selectedContentType && !isContentTypeChooserOpen && (
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4"> <div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-emerald-400/30 bg-emerald-400/10">
<img
src={`/gfx/mascot_${getContentTypeVisualKey(selectedContentType)}.webp`}
alt=""
className="h-7 w-7 object-contain"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
</div>
<div> <div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-200/80">Selected content type</div> <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-200/70">Selected</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedContentType.name}</div> <div className="mt-0.5 text-base font-semibold text-white">{selectedContentType.name}</div>
<div className="mt-1 text-sm text-slate-400">
{filteredCategoryTree.length > 0
? `Continue by choosing one of the ${filteredCategoryTree.length} matching categories below.`
: 'This content type does not have categories yet.'}
</div> </div>
</div> </div>
<button <button
@@ -211,7 +225,6 @@ export default function Step2Details({
const isActive = typeValue === String(metadata.contentType || '') const isActive = typeValue === String(metadata.contentType || '')
const visualKey = getContentTypeVisualKey(ct) const visualKey = getContentTypeVisualKey(ct)
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0 const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
return ( return (
<button <button
key={typeValue || ct.name} key={typeValue || ct.name}
@@ -251,43 +264,28 @@ export default function Step2Details({
)} )}
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>} {metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
</section>
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(168,85,247,0.08),_rgba(15,23,36,0.88)_55%)] p-5 sm:p-6">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<h4 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Category path</h4>
<p className="mt-1 text-sm text-slate-400">Choose the main branch first, then refine with a subcategory when needed.</p>
</div>
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
Step 2b
</span>
</div> </div>
{!selectedContentType && ( {/* ── Category ── */}
<div className="mt-5 rounded-2xl border border-dashed border-white/12 bg-white/[0.02] px-5 py-10 text-center">
<div className="text-sm font-medium text-white">Select a content type first</div>
<p className="mt-2 text-sm text-slate-500">Once you choose the content type, the matching category tree will appear here.</p>
</div>
)}
{selectedContentType && ( {selectedContentType && (
<div className="mt-5 space-y-5"> <>
<div className="flex items-center gap-2 text-sm text-slate-400"> <div className="my-5 border-t border-white/8" />
<span className="rounded-full border border-emerald-400/20 bg-emerald-400/10 px-2.5 py-1 text-emerald-200">{selectedContentType.name}</span> <div>
<span>contains {filteredCategoryTree.length} top-level {filteredCategoryTree.length === 1 ? 'category' : 'categories'}</span> <div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</p>
<span className="text-[11px] text-slate-600">{filteredCategoryTree.length} available</span>
</div> </div>
{selectedRoot && !isCategoryChooserOpen && ( {selectedRoot && !isCategoryChooserOpen && (
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.08] p-4"> <div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.07] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-purple-200/80">Selected category</div> <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-purple-200/70">Selected</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedRoot.name}</div> <div className="mt-0.5 text-base font-semibold text-white">{selectedRoot.name}</div>
<div className="mt-1 text-sm text-slate-400"> <div className="mt-1 text-xs text-slate-500">
{subCategories.length > 0 {subCategories.length > 0
? `Next step: choose one of the ${subCategories.length} subcategories below.` ? `${subCategories.length} subcategories available`
: 'This category is complete. No subcategory is required.'} : 'No subcategory required'}
</div> </div>
</div> </div>
<button <button
@@ -320,7 +318,6 @@ export default function Step2Details({
{sortedFilteredCategories.map((cat) => { {sortedFilteredCategories.map((cat) => {
const isActive = String(metadata.rootCategoryId || '') === String(cat.id) const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
const childCount = cat.children?.length || 0 const childCount = cat.children?.length || 0
return ( return (
<button <button
key={cat.id} key={cat.id}
@@ -339,7 +336,7 @@ export default function Step2Details({
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div> <div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories available` : 'Standalone category'}</div> <div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories` : 'Standalone'}</div>
</div> </div>
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}> <span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
{isActive ? 'Selected' : 'Choose'} {isActive ? 'Selected' : 'Choose'}
@@ -352,30 +349,35 @@ export default function Step2Details({
</div> </div>
)} )}
{selectedRoot && subCategories.length > 0 && ( {metadataErrors.category && <p className="mt-3 text-xs text-red-300">{metadataErrors.category}</p>}
<div className="rounded-2xl border border-cyan-400/15 bg-cyan-400/[0.05] p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h5 className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Subcategories</h5>
<p className="mt-1 text-sm text-slate-400">Refine <span className="text-white">{selectedRoot.name}</span> with one more level.</p>
</div> </div>
<span className="rounded-full border border-cyan-400/20 bg-cyan-400/10 px-2 py-1 text-[11px] text-cyan-200">{subCategories.length}</span> </>
)}
{/* ── Subcategory ── */}
{selectedRoot && subCategories.length > 0 && (
<>
<div className="my-5 border-t border-white/8" />
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Subcategory</p>
<span className="text-[11px] text-slate-600">{subCategories.length} available</span>
</div> </div>
{!metadata.subCategoryId && requiresSubCategory && ( {!metadata.subCategoryId && requiresSubCategory && (
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100"> <div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
Subcategory still needs to be selected. Subcategory still needs to be selected.
</div> </div>
)} )}
{selectedSubCategory && !isSubCategoryChooserOpen && ( {selectedSubCategory && !isSubCategoryChooserOpen && (
<div className="mt-4 rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.09] p-4"> <div className="rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.07] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div> <div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-cyan-200/80">Selected subcategory</div> <div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-cyan-200/70">Selected</div>
<div className="mt-1 text-lg font-semibold text-white">{selectedSubCategory.name}</div> <div className="mt-0.5 text-base font-semibold text-white">{selectedSubCategory.name}</div>
<div className="mt-1 text-sm text-slate-300"> <div className="mt-1 text-xs text-slate-500">
Final category path: <span className="text-white">{selectedRoot.name}</span> / <span className="text-cyan-100">{selectedSubCategory.name}</span> Path: <span className="text-slate-300">{selectedRoot.name}</span> / <span className="text-cyan-200">{selectedSubCategory.name}</span>
</div> </div>
</div> </div>
<button <button
@@ -390,7 +392,7 @@ export default function Step2Details({
)} )}
{(!selectedSubCategory || isSubCategoryChooserOpen) && ( {(!selectedSubCategory || isSubCategoryChooserOpen) && (
<div className="mt-4 space-y-3"> <div className="space-y-3">
<div className="relative"> <div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg> <svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
<input <input
@@ -424,25 +426,14 @@ export default function Step2Details({
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className={[ <div className={['text-sm font-semibold transition-colors', isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white'].join(' ')}>
'text-sm font-semibold transition-colors',
isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white',
].join(' ')}>
{sub.name} {sub.name}
</div> </div>
<div className={[ <div className={['mt-1 text-xs', isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300'].join(' ')}>
'mt-1 text-xs', Subcategory
isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300',
].join(' ')}>
Subcategory option
</div> </div>
</div> </div>
<span className={[ <span className={['shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium', isActive ? 'bg-cyan-300/15 text-cyan-100' : 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300'].join(' ')}>
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium',
isActive
? 'bg-cyan-300/15 text-cyan-100'
: 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300',
].join(' ')}>
{isActive ? 'Selected' : 'Choose'} {isActive ? 'Selected' : 'Choose'}
</span> </span>
</div> </div>
@@ -453,17 +444,14 @@ export default function Step2Details({
</div> </div>
)} )}
</div> </div>
</>
)} )}
{selectedRoot && subCategories.length === 0 && ( {selectedRoot && subCategories.length === 0 && selectedRoot && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400"> <div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">
<span className="font-medium text-white">{selectedRoot.name}</span> does not have subcategories. Selecting it is enough. <span className="font-medium text-slate-300">{selectedRoot.name}</span> has no subcategories selecting it is enough.
</div> </div>
)} )}
</div>
)}
{metadataErrors.category && <p className="mt-4 text-xs text-red-300">{metadataErrors.category}</p>}
</section> </section>
{/* Title, tags, description, rights */} {/* Title, tags, description, rights */}
@@ -472,6 +460,11 @@ export default function Step2Details({
metadata={metadata} metadata={metadata}
suggestedTags={suggestedTags} suggestedTags={suggestedTags}
errors={metadataErrors} errors={metadataErrors}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={timezone}
onPublishModeChange={onPublishModeChange}
onScheduleAt={onScheduleAt}
onChangeTitle={onChangeTitle} onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags} onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription} onChangeDescription={onChangeDescription}

View File

@@ -56,6 +56,7 @@ export default function Step3Publish({
scheduledAt = null, scheduledAt = null,
timezone = null, timezone = null,
visibility = 'public', visibility = 'public',
onVisibilityChange,
// Category tree (for label lookup) // Category tree (for label lookup)
allRootCategoryOptions = [], allRootCategoryOptions = [],
filteredCategoryTree = [], filteredCategoryTree = [],
@@ -162,7 +163,45 @@ export default function Step3Publish({
</div> </div>
</div> </div>
{/* Publish summary: visibility + schedule */} {/* ── Visibility selector ────────────────────────────────────────── */}
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p>
<div className="grid gap-2 sm:grid-cols-3">
{[
{ value: 'public', label: 'Public', hint: 'Visible to everyone' },
{ value: 'unlisted', label: 'Unlisted', hint: 'Available by direct link' },
{ value: 'private', label: 'Private', hint: 'Keep as draft visibility' },
].map((option) => {
const active = visibility === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onVisibilityChange?.(option.value)}
className={[
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition',
active
? 'border-sky-300/30 bg-sky-400/10 text-white shadow-[0_0_0_1px_rgba(56,189,248,0.12)]'
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs text-white/45">{option.hint}</div>
</div>
<span className={[
'mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px]',
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/30',
].join(' ')}>
{active ? '✓' : ''}
</span>
</button>
)
})}
</div>
</section>
{/* Publish summary: schedule info */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60"> <span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60">
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'} 👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'}

View File

@@ -7,14 +7,28 @@
@php @php
$active = $section ?? 'artworks'; $active = $section ?? 'artworks';
$includeTags = (bool) ($includeTags ?? false); $includeTags = (bool) ($includeTags ?? false);
$contentTypes = collect($contentTypes ?? $mainCategories ?? []);
$iconMap = [
'photography' => 'fa-camera',
'wallpapers' => 'fa-desktop',
'skins' => 'fa-layer-group',
'digital-art' => 'fa-palette',
'other' => 'fa-folder-open',
];
$sections = collect([ $sections = collect([
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'], 'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
'photography' => ['label' => 'Photography', 'icon' => 'fa-camera', 'href' => '/photography'], ])->merge(
'wallpapers' => ['label' => 'Wallpapers', 'icon' => 'fa-desktop', 'href' => '/wallpapers'], $contentTypes->mapWithKeys(function ($type) use ($iconMap) {
'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'], $slug = strtolower((string) ($type->slug ?? ''));
'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'],
]); return [$slug => [
'label' => $type->name,
'icon' => $iconMap[$slug] ?? 'fa-folder-open',
'href' => $type->url ?? ('/' . $slug),
]];
})
);
if ($includeTags) { if ($includeTags) {
$sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']); $sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']);

View File

@@ -113,6 +113,7 @@
'photography' => 'fa-camera', 'photography' => 'fa-camera',
'wallpapers' => 'fa-desktop', 'wallpapers' => 'fa-desktop',
'skins' => 'fa-layer-group', 'skins' => 'fa-layer-group',
'digital-art' => 'fa-palette',
'other' => 'fa-folder-open', 'other' => 'fa-folder-open',
'tags' => 'fa-tags', 'tags' => 'fa-tags',
]; ];
@@ -130,20 +131,14 @@
$headerBreadcrumbs = collect(); $headerBreadcrumbs = collect();
if (($gallery_type ?? null) === 'browse') { if (($gallery_type ?? null) === 'browse') {
$headerBreadcrumbs = collect([ $headerBreadcrumbs = $breadcrumbs ?? collect();
(object) ['name' => 'Browse', 'url' => '/browse'],
]);
} elseif (isset($contentType) && $contentType) {
$headerBreadcrumbs = collect([
(object) ['name' => 'Browse', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
]);
if (($gallery_type ?? null) === 'category' && isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
$headerBreadcrumbs = $breadcrumbs;
}
} elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) { } elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
$headerBreadcrumbs = $breadcrumbs; $headerBreadcrumbs = $breadcrumbs;
} elseif (isset($contentType) && $contentType) {
$headerBreadcrumbs = collect([
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
]);
} }
@endphp @endphp
@@ -426,95 +421,6 @@
</div> </div>
@endsection @endsection
@push('head')
<style>
/* ── Hero ─────────────────────────────────────────────────────── */
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
.nb-hero-gradient {
background: linear-gradient(135deg, rgba(224,122,33,0.08) 0%, rgba(15,23,36,0) 50%, rgba(21,36,58,0.4) 100%);
animation: nb-hero-shimmer 8s ease-in-out infinite alternate;
}
@keyframes nb-hero-shimmer {
0% { opacity: 0.6; }
100% { opacity: 1; }
}
/* ── Ranking Tabs ─────────────────────────────────────────────── */
.gallery-rank-tab {
-webkit-tap-highlight-color: transparent;
}
.gallery-rank-tab .nb-tab-indicator {
transition: transform 300ms cubic-bezier(0.4, 0, 0.2, 1), background-color 200ms ease;
}
/* Legacy: keep nb-scrollbar-none working elsewhere in the page */
.nb-scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.nb-scrollbar-none::-webkit-scrollbar { display: none; }
/* ── Gallery grid fade-in on page load / tab change ─────────── */
@keyframes nb-gallery-fade-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
[data-react-masonry-gallery] {
animation: nb-gallery-fade-in 300ms ease-out both;
}
/* ── Filter panel choice pills ───────────────────────────────── */
.nb-filter-choice { display: inline-flex; cursor: pointer; }
.nb-filter-choice--block { display: flex; width: 100%; }
.nb-filter-choice-label {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05);
color: rgba(214,224,238,0.8);
font-size: 0.8125rem;
font-weight: 500;
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
white-space: nowrap;
}
.nb-filter-choice--block .nb-filter-choice-label {
border-radius: 0.6rem;
width: 100%;
}
.nb-filter-choice input:checked ~ .nb-filter-choice-label {
background: #E07A21;
border-color: #E07A21;
color: #fff;
box-shadow: 0 1px 8px rgba(224,122,33,0.35);
}
.nb-filter-choice input:focus-visible ~ .nb-filter-choice-label {
outline: 2px solid rgba(224,122,33,0.6);
outline-offset: 2px;
}
/* Filter date/text inputs */
.nb-filter-input {
appearance: none;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 0.5rem;
color: rgba(255,255,255,0.85);
font-size: 0.8125rem;
padding: 0.425rem 0.75rem;
transition: border-color 150ms ease;
color-scheme: dark;
}
.nb-filter-input:focus {
outline: none;
border-color: rgba(224,122,33,0.6);
box-shadow: 0 0 0 3px rgba(224,122,33,0.15);
}
</style>
@endpush
@push('scripts') @push('scripts')
@vite('resources/js/entry-masonry-gallery.jsx') @vite('resources/js/entry-masonry-gallery.jsx')
@vite('resources/js/entry-pill-carousel.jsx') @vite('resources/js/entry-pill-carousel.jsx')

View File

@@ -49,33 +49,6 @@
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" /> <link rel="manifest" href="/favicon/site.webmanifest" />
@vite($novaViteEntries) @vite($novaViteEntries)
<style>
/* Card enter animation */
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }
.nova-card-enter.nova-card-enter-active { transition: transform 380ms cubic-bezier(.2,.9,.2,1), opacity 380ms ease-out; opacity: 1; transform: none; }
/* Auth card consistency */
.auth-card { max-width: 720px; margin-left: auto; margin-right: auto; }
.auth-card h1 { font-size: 1.25rem; line-height: 1.2; }
.auth-card p { color: rgba(203,213,225,0.9); }
/* Global heading styles for better hierarchy */
h1, h2, h3, h4, h5, h6 { color: #ffffff; margin-top: 1rem; margin-bottom: 0.5rem; }
h1 { font-size: 2.25rem; line-height: 1.05; font-weight: 800; letter-spacing: -0.02em; }
h2 { font-size: 1.5rem; line-height: 1.15; font-weight: 700; letter-spacing: -0.01em; }
h3 { font-size: 1.125rem; line-height: 1.2; font-weight: 600; }
h4 { font-size: 1rem; line-height: 1.25; font-weight: 600; }
h5 { font-size: 0.95rem; line-height: 1.25; font-weight: 600; }
h6 { font-size: 0.85rem; line-height: 1.3; font-weight: 600; text-transform: uppercase; opacity: 0.85; }
/* Prose (typography plugin) overrides */
.prose h1 { font-size: 2.25rem; }
.prose h2 { font-size: 1.5rem; }
.prose h3 { font-size: 1.125rem; }
.prose h4, .prose h5, .prose h6 { font-weight: 600; }
/* Alpine: hide x-cloak elements until Alpine picks them up */
[x-cloak] { display: none !important; }
</style>
@stack('head') @stack('head')
@if($deferToolbarSearch) @if($deferToolbarSearch)

View File

@@ -33,7 +33,7 @@ Route::redirect('/browse-categories', '/categories', 301)->name('browse.categori
// /Skins/BrowserBob/210 // /Skins/BrowserBob/210
// /Skins/BrowserBob/sdsdsdsd/210 // /Skins/BrowserBob/sdsdsdsd/210
Route::get('/{group}/{slug}/{id}', CategoryRedirectController::class) Route::get('/{group}/{slug}/{id}', CategoryRedirectController::class)
->where('group', '(?i:skins|wallpapers|photography|other|members)') ->where('group', '(?i:skins|wallpapers|photography|other|digital-art|members)')
->where('slug', '[^/]+(?:/[^/]+)*') ->where('slug', '[^/]+(?:/[^/]+)*')
->whereNumber('id') ->whereNumber('id')
->name('legacy.category.short'); ->name('legacy.category.short');

View File

@@ -79,10 +79,10 @@ Route::prefix('explore')->name('explore.')->group(function () {
Route::get('/members', fn () => redirect()->route('creators.top', request()->query(), 301))->name('members.redirect'); Route::get('/members', fn () => redirect()->route('creators.top', request()->query(), 301))->name('members.redirect');
Route::get('/memebers', fn () => redirect()->route('creators.top', request()->query(), 301))->name('memebers.redirect'); Route::get('/memebers', fn () => redirect()->route('creators.top', request()->query(), 301))->name('memebers.redirect');
Route::get('/{type}', [ExploreController::class, 'byType']) Route::get('/{type}', [ExploreController::class, 'byType'])
->where('type', 'artworks|wallpapers|skins|photography|other') ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other')
->name('type'); ->name('type');
Route::get('/{type}/{mode}', [ExploreController::class, 'byTypeMode']) Route::get('/{type}/{mode}', [ExploreController::class, 'byTypeMode'])
->where('type', 'artworks|wallpapers|skins|photography|other') ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other')
->where('mode', 'trending|new-hot|best|latest') ->where('mode', 'trending|new-hot|best|latest')
->name('type.mode'); ->name('type.mode');
}); });
@@ -140,10 +140,10 @@ Route::middleware('throttle:60,1')->group(function () {
Route::prefix('rss/explore')->name('rss.explore.')->group(function () { Route::prefix('rss/explore')->name('rss.explore.')->group(function () {
Route::get('/{type}', [ExploreFeedController::class, 'byType']) Route::get('/{type}', [ExploreFeedController::class, 'byType'])
->where('type', 'artworks|wallpapers|skins|photography|other') ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other')
->name('type'); ->name('type');
Route::get('/{type}/{mode}', [ExploreFeedController::class, 'byTypeMode']) Route::get('/{type}/{mode}', [ExploreFeedController::class, 'byTypeMode'])
->where('type', 'artworks|wallpapers|skins|photography|other') ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other')
->where('mode', 'trending|latest|best') ->where('mode', 'trending|latest|best')
->name('type.mode'); ->name('type.mode');
}); });
@@ -551,7 +551,7 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])-
// ── UPLOAD ──────────────────────────────────────────────────────────────────── // ── UPLOAD ────────────────────────────────────────────────────────────────────
Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () { Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
Route::get('/upload', function () { Route::get('/upload', function () {
$contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { $contentTypes = ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [ return [
'id' => $ct->id, 'id' => $ct->id,
'name' => $ct->name, 'name' => $ct->name,
@@ -580,7 +580,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () {
})->name('upload'); })->name('upload');
Route::get('/upload/draft/{id}', function (string $id) { Route::get('/upload/draft/{id}', function (string $id) {
$contentTypes = ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { $contentTypes = ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [ return [
'id' => $ct->id, 'id' => $ct->id,
'name' => $ct->name, 'name' => $ct->name,
@@ -666,12 +666,12 @@ Route::bind('artwork', function ($value) {
}); });
Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork']) Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork'])
->where('contentTypeSlug', 'photography|wallpapers|skins|other') ->where('contentTypeSlug', 'photography|wallpapers|skins|other|digital-art')
->where('categoryPath', '[^/]+(?:/[^/]+)*') ->where('categoryPath', '[^/]+(?:/[^/]+)*')
->name('artworks.show'); ->name('artworks.show');
Route::get('/{contentTypeSlug}/{path?}', [BrowseGalleryController::class, 'content']) Route::get('/{contentTypeSlug}/{path?}', [BrowseGalleryController::class, 'content'])
->where('contentTypeSlug', 'photography|wallpapers|skins|other') ->where('contentTypeSlug', 'photography|wallpapers|skins|other|digital-art')
->where('path', '.*') ->where('path', '.*')
->name('content.route'); ->name('content.route');

View File

@@ -1,8 +1,8 @@
#!/bin/bash #!/bin/bash
localFolder='/mnt/d/Sites/Skinbase26/' localFolder='/mnt/d/Sites/Skinbase26/'
remoteFolder='/opt/www/virtual/SkinbaseDev/' remoteFolder='/opt/www/virtual/SkinbaseNova/'
remoteServer='klevze@server3.klevze.si' remoteServer='klevze@nastja.klevze.si'
rsync -avz \ rsync -avz \
--chmod=D755,F644 \ --chmod=D755,F644 \