diff --git a/app/Http/Controllers/Dashboard/DashboardGalleryController.php b/app/Http/Controllers/Dashboard/DashboardGalleryController.php index 3a0337bb..ead9c6e4 100644 --- a/app/Http/Controllers/Dashboard/DashboardGalleryController.php +++ b/app/Http/Controllers/Dashboard/DashboardGalleryController.php @@ -32,7 +32,7 @@ class DashboardGalleryController extends Controller $artworks->getCollection()->map(fn (Artwork $artwork) => $this->presentArtwork($artwork)) ); - $mainCategories = ContentType::orderBy('id') + $mainCategories = ContentType::ordered() ->get(['name', 'slug']) ->map(function (ContentType $type) { return (object) [ diff --git a/app/Http/Controllers/Legacy/BrowseController.php b/app/Http/Controllers/Legacy/BrowseController.php index 3757052b..23f3627c 100644 --- a/app/Http/Controllers/Legacy/BrowseController.php +++ b/app/Http/Controllers/Legacy/BrowseController.php @@ -57,7 +57,7 @@ class BrowseController extends Controller // Shape data for the legacy Blade while using authoritative tables only. $artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork)); - $rootCategories = ContentType::orderBy('id') + $rootCategories = ContentType::ordered() ->get(['name', 'slug']) ->map(fn (ContentType $type) => (object) [ 'name' => $type->name, diff --git a/app/Http/Controllers/Studio/StudioController.php b/app/Http/Controllers/Studio/StudioController.php index d21c9bf2..e65be6c4 100644 --- a/app/Http/Controllers/Studio/StudioController.php +++ b/app/Http/Controllers/Studio/StudioController.php @@ -164,7 +164,7 @@ final class StudioController extends Controller private function getCategories(): array { - return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) { + return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) { return [ 'id' => $ct->id, 'name' => $ct->name, diff --git a/app/Http/Controllers/User/ProfileCoverController.php b/app/Http/Controllers/User/ProfileCoverController.php index ac7f15ed..5d32f195 100644 --- a/app/Http/Controllers/User/ProfileCoverController.php +++ b/app/Http/Controllers/User/ProfileCoverController.php @@ -7,11 +7,9 @@ use App\Support\CoverUrl; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; 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\Imagick\Driver as ImagickDriver; -use Intervention\Image\Encoders\JpegEncoder; -use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\ImageManager; 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 @@ -152,15 +150,12 @@ class ProfileCoverController extends Controller $p1 = substr($hash, 0, 2); $p2 = substr($hash, 2, 2); - return $this->storageRoot() - . DIRECTORY_SEPARATOR . 'covers' - . DIRECTORY_SEPARATOR . $p1 - . DIRECTORY_SEPARATOR . $p2; + return 'covers/' . $p1 . '/' . $p2; } 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 { $this->assertImageManager(); + $this->assertStorageIsAllowed(); $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname()); 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); $processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center'); + $ext = 'webp'; $encoded = $this->encodeByExtension($processed, $ext); $hash = hash('sha256', $encoded); - $dir = $this->coverDirectory($hash); - if (! File::exists($dir)) { - File::makeDirectory($dir, 0755, true); - } + $disk = Storage::disk($this->coverDiskName()); + $written = $disk->put($this->coverPath($hash, $ext), $encoded, [ + '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]; } @@ -220,8 +224,6 @@ class ProfileCoverController extends Controller private function encodeByExtension($image, string $ext): string { return match ($ext) { - 'jpg' => (string) $image->encode(new JpegEncoder(85)), - 'png' => (string) $image->encode(new PngEncoder()), default => (string) $image->encode(new WebpEncoder(85)), }; } @@ -235,10 +237,7 @@ class ProfileCoverController extends Controller return; } - $path = $this->coverPath($trimHash, $trimExt); - if (is_file($path)) { - @unlink($path); - } + Storage::disk($this->coverDiskName())->delete($this->coverPath($trimHash, $trimExt)); } private function assertImageManager(): void @@ -249,4 +248,16 @@ class ProfileCoverController extends Controller 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.'); + } + } } diff --git a/app/Http/Controllers/Web/BrowseCategoriesController.php b/app/Http/Controllers/Web/BrowseCategoriesController.php index f45bbed6..80b27221 100644 --- a/app/Http/Controllers/Web/BrowseCategoriesController.php +++ b/app/Http/Controllers/Web/BrowseCategoriesController.php @@ -10,7 +10,7 @@ class BrowseCategoriesController extends Controller { 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 = []; $categories = collect(); diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index 88b1f9f4..a74861c7 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -17,7 +17,7 @@ use Illuminate\Pagination\AbstractCursorPaginator; 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. @@ -108,7 +108,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'sort_options' => self::SORT_OPTIONS, 'hero_title' => 'Browse Artworks', '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_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', @@ -164,7 +164,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'sort_options' => self::SORT_OPTIONS, 'hero_title' => $contentType->name, '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_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'), 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', @@ -204,8 +207,17 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller $subcategories = $rootCategories; } - $breadcrumbs = collect($category->breadcrumbs) - ->map(function (Category $crumb) { + $breadcrumbs = collect(array_merge([ + (object) [ + 'name' => 'Explore', + 'url' => '/browse', + ], + (object) [ + 'name' => $contentType->name, + 'url' => '/' . $contentSlug, + ], + ], $category->breadcrumbs)) + ->map(function ($crumb) { return (object) [ 'name' => $crumb->name, 'url' => $crumb->url, @@ -344,7 +356,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller private function mainCategories(): Collection { - return ContentType::orderBy('id') + return ContentType::ordered() + ->whereIn('slug', self::CONTENT_TYPE_SLUGS) ->get(['name', 'slug']) ->map(function (ContentType $type) { return (object) [ diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php index 3456e0fa..a80261c8 100644 --- a/app/Http/Controllers/Web/ExploreController.php +++ b/app/Http/Controllers/Web/ExploreController.php @@ -225,7 +225,7 @@ final class ExploreController extends Controller private function mainCategories(): Collection { - $categories = ContentType::orderBy('id') + $categories = ContentType::ordered() ->get(['name', 'slug']) ->map(fn ($ct) => (object) [ 'name' => $ct->name, diff --git a/app/Http/Controllers/Web/SectionsController.php b/app/Http/Controllers/Web/SectionsController.php index 323aa7aa..6b9b67e7 100644 --- a/app/Http/Controllers/Web/SectionsController.php +++ b/app/Http/Controllers/Web/SectionsController.php @@ -24,7 +24,7 @@ class SectionsController extends Controller ->orderBy('sort_order') ->orderBy('name'); }, - ])->orderBy('id')->get(); + ])->ordered()->get(); // Total artwork counts per content type via a single aggregation query $artworkCountsByType = DB::table('artworks') diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index e3c84059..df00402a 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -73,7 +73,7 @@ final class TagController extends Controller $artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories'])); // 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) [ 'id' => $type->id, 'name' => $type->name, diff --git a/app/Models/ContentType.php b/app/Models/ContentType.php index f1fe5ee6..30f29fdf 100644 --- a/app/Models/ContentType.php +++ b/app/Models/ContentType.php @@ -10,7 +10,16 @@ use App\Models\Artwork; 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 { diff --git a/app/Services/Studio/StudioAiCategoryMapper.php b/app/Services/Studio/StudioAiCategoryMapper.php index a7e52b7a..b5e94cdd 100644 --- a/app/Services/Studio/StudioAiCategoryMapper.php +++ b/app/Services/Studio/StudioAiCategoryMapper.php @@ -20,7 +20,7 @@ final class StudioAiCategoryMapper $tokens = $this->tokenize($signals); $haystack = ' ' . implode(' ', $tokens) . ' '; - $contentTypes = ContentType::query()->with(['rootCategories.children'])->get(); + $contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get(); $contentTypeScores = $contentTypes ->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack)) ->filter(fn (array $row): bool => $row['score'] > 0) diff --git a/config/covers.php b/config/covers.php new file mode 100644 index 00000000..1e762312 --- /dev/null +++ b/config/covers.php @@ -0,0 +1,5 @@ + env('COVER_DISK', env('AVATAR_DISK', 's3')), +]; \ No newline at end of file diff --git a/database/migrations/2026_03_28_200000_add_order_to_content_types_table.php b/database/migrations/2026_03_28_200000_add_order_to_content_types_table.php new file mode 100644 index 00000000..5f7d478c --- /dev/null +++ b/database/migrations/2026_03_28_200000_add_order_to_content_types_table.php @@ -0,0 +1,35 @@ +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'); + }); + } +}; \ No newline at end of file diff --git a/resources/css/app.css b/resources/css/app.css index 463ecf15..e97ee44a 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -53,6 +53,21 @@ input[type="file"] { @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; 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 { + .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 { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.14) transparent; @@ -171,6 +265,106 @@ .messages-page *::-webkit-scrollbar-corner { 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 ─── */ diff --git a/resources/js/Pages/Studio/StudioArtworkEdit.jsx b/resources/js/Pages/Studio/StudioArtworkEdit.jsx index cd2174d1..54304ba6 100644 --- a/resources/js/Pages/Studio/StudioArtworkEdit.jsx +++ b/resources/js/Pages/Studio/StudioArtworkEdit.jsx @@ -6,6 +6,7 @@ import TextInput from '../../components/ui/TextInput' import Button from '../../components/ui/Button' import Modal from '../../components/ui/Modal' import FormField from '../../components/ui/FormField' +import Toggle from '../../components/ui/Toggle' import TagPicker from '../../components/tags/TagPicker' import SchedulePublishPicker from '../../components/upload/SchedulePublishPicker' diff --git a/resources/js/Pages/Upload/Index.jsx b/resources/js/Pages/Upload/Index.jsx index b8b1da87..8a9a159c 100644 --- a/resources/js/Pages/Upload/Index.jsx +++ b/resources/js/Pages/Upload/Index.jsx @@ -609,11 +609,11 @@ export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) { if (uploadsV2Enabled) { return (
-
+
{/* ── Wizard ─────────────────────────────────────────────────────── */} -
+
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 hasRights = Boolean(metadata.rightsAccepted) const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0 @@ -75,7 +83,7 @@ export default function PublishPanel({ const checklist = [ { label: 'File uploaded & processed', ok: uploadReady }, { 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) }, ...( isArchiveRequiresScreenshot ? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }] @@ -162,7 +170,8 @@ export default function PublishPanel({
- {/* Visibility */} + {/* Visibility (only when showVisibility=true) */} + {showVisibility && (
+ )} - {/* Schedule picker – only shows when upload is ready */} - {uploadReady && machineState !== 'complete' && ( + {/* Schedule picker – only shows when enabled for this panel */} + {showVisibility && uploadReady && machineState !== 'complete' && ( +
{showHeader && (

{title}

@@ -77,6 +83,24 @@ export default function UploadSidebar({ />
+ {typeof publishMode === 'string' && typeof onPublishModeChange === 'function' && ( +
+
+

Publish settings

+

Choose whether this artwork should publish immediately or on a schedule.

+
+ + +
+ )} +
- + ) } diff --git a/resources/js/components/upload/UploadWizard.jsx b/resources/js/components/upload/UploadWizard.jsx index b564926c..6d7f8444 100644 --- a/resources/js/components/upload/UploadWizard.jsx +++ b/resources/js/components/upload/UploadWizard.jsx @@ -240,11 +240,22 @@ export default function UploadWizard({ const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, 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(() => ( uploadReady && + hasTitle && + hasCompleteCategory && + hasTag && + hasRequiredScreenshot && metadata.rightsAccepted && machine.state !== machineStates.publishing - ), [uploadReady, metadata.rightsAccepted, machine.state]) + ), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state]) const canScheduleSubmit = useMemo(() => { if (!canPublish) return false @@ -424,6 +435,11 @@ export default function UploadWizard({ onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })} onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })} suggestedTags={mergedSuggestedTags} + publishMode={publishMode} + scheduledAt={scheduledAt} + timezone={userTimezone} + onPublishModeChange={setPublishMode} + onScheduleAt={setScheduledAt} onChangeTitle={(value) => setMeta({ title: value })} onChangeTags={(value) => setMeta({ tags: value })} onChangeDescription={(value) => setMeta({ description: value })} @@ -448,6 +464,7 @@ export default function UploadWizard({ scheduledAt={scheduledAt} timezone={userTimezone} visibility={visibility} + onVisibilityChange={setVisibility} allRootCategoryOptions={allRootCategoryOptions} filteredCategoryTree={filteredCategoryTree} /> @@ -601,6 +618,7 @@ export default function UploadWizard({ timezone={userTimezone} visibility={visibility} showRightsConfirmation={activeStep === 3} + showVisibility={false} onPublishModeChange={setPublishMode} onScheduleAt={setScheduledAt} onVisibilityChange={setVisibility} @@ -608,6 +626,7 @@ export default function UploadWizard({ onPublish={() => handlePublish(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })} onCancel={handleCancel} onGoToStep={goToStep} + allRootCategoryOptions={allRootCategoryOptions} /> )} @@ -628,7 +647,14 @@ export default function UploadWizard({ Publish {!canPublish && ( - {[...(!uploadReady ? [1] : []), ...(!metadata.title ? [1] : []), ...(!metadata.rightsAccepted ? [1] : [])].length} + {[ + ...(!uploadReady ? [1] : []), + ...(hasTitle ? [] : [1]), + ...(hasCompleteCategory ? [] : [1]), + ...(hasTag ? [] : [1]), + ...(hasRequiredScreenshot ? [] : [1]), + ...(metadata.rightsAccepted ? [] : [1]), + ].length} )} @@ -674,6 +700,7 @@ export default function UploadWizard({ timezone={userTimezone} visibility={visibility} showRightsConfirmation={activeStep === 3} + showVisibility={false} onPublishModeChange={setPublishMode} onScheduleAt={setScheduledAt} onVisibilityChange={setVisibility} @@ -690,6 +717,7 @@ export default function UploadWizard({ setShowMobilePublishPanel(false) goToStep(s) }} + allRootCategoryOptions={allRootCategoryOptions} /> diff --git a/resources/js/components/upload/steps/Step2Details.jsx b/resources/js/components/upload/steps/Step2Details.jsx index 4f9b0e76..d35a7f98 100644 --- a/resources/js/components/upload/steps/Step2Details.jsx +++ b/resources/js/components/upload/steps/Step2Details.jsx @@ -29,6 +29,11 @@ export default function Step2Details({ onSubCategoryChange, // Sidebar (title / tags / description / rights) suggestedTags, + publishMode, + scheduledAt, + timezone, + onPublishModeChange, + onScheduleAt, onChangeTitle, onChangeTags, onChangeDescription, @@ -164,247 +169,244 @@ export default function Step2Details({ -
-
+ {/* ── Combined: Content type → Category → Subcategory ─────────────────── */} +
+ {/* Section header */} +
-

Content type

-

Choose the main content family first.

+

Content type & category

+

Choose the content family, then narrow down to a category and subcategory.

- - Step 2a - + Step 2
- {contentTypeOptions.length === 0 && ( -
- No content types are available right now. -
- )} + {/* ── Content type ── */} +
+

Content type

- {selectedContentType && !isContentTypeChooserOpen && ( -
-
-
-
Selected content type
-
{selectedContentType.name}
-
- {filteredCategoryTree.length > 0 - ? `Continue by choosing one of the ${filteredCategoryTree.length} matching categories below.` - : 'This content type does not have categories yet.'} -
-
- + {contentTypeOptions.length === 0 && ( +
+ No content types are available right now.
-
- )} + )} - {(!selectedContentType || isContentTypeChooserOpen) && ( -
- {contentTypeOptions.map((ct) => { - const typeValue = String(getContentTypeValue(ct)) - const isActive = typeValue === String(metadata.contentType || '') - const visualKey = getContentTypeVisualKey(ct) - const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0 - - return ( - - ) - })} -
- )} - - {metadataErrors.contentType &&

{metadataErrors.contentType}

} -
- -
-
-
-

Category path

-

Choose the main branch first, then refine with a subcategory when needed.

-
- - Step 2b - -
- - {!selectedContentType && ( -
-
Select a content type first
-

Once you choose the content type, the matching category tree will appear here.

-
- )} - - {selectedContentType && ( -
-
- {selectedContentType.name} - contains {filteredCategoryTree.length} top-level {filteredCategoryTree.length === 1 ? 'category' : 'categories'} -
- - {selectedRoot && !isCategoryChooserOpen && ( -
-
-
-
Selected category
-
{selectedRoot.name}
-
- {subCategories.length > 0 - ? `Next step: choose one of the ${subCategories.length} subcategories below.` - : 'This category is complete. No subcategory is required.'} -
+ {selectedContentType && !isContentTypeChooserOpen && ( +
+
+
+
+ { e.currentTarget.style.display = 'none' }} + />
+
+
Selected
+
{selectedContentType.name}
+
+
+ +
+
+ )} + + {(!selectedContentType || isContentTypeChooserOpen) && ( +
+ {contentTypeOptions.map((ct) => { + const typeValue = String(getContentTypeValue(ct)) + const isActive = typeValue === String(metadata.contentType || '') + const visualKey = getContentTypeVisualKey(ct) + const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0 + return ( -
-
- )} - - {(!selectedRoot || isCategoryChooserOpen) && ( -
-
- - setCategorySearch(e.target.value)} - placeholder="Search categories…" - className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30" - /> -
- {sortedFilteredCategories.length === 0 && ( -

No categories match “{categorySearch}”

- )} -
- {sortedFilteredCategories.map((cat) => { - const isActive = String(metadata.rootCategoryId || '') === String(cat.id) - const childCount = cat.children?.length || 0 - - return ( - - ) - })} -
-
- )} - - {selectedRoot && subCategories.length > 0 && ( -
-
-
-
Subcategories
-

Refine {selectedRoot.name} with one more level.

-
- {subCategories.length} -
- - {!metadata.subCategoryId && requiresSubCategory && ( -
- Subcategory still needs to be selected. -
- )} - - {selectedSubCategory && !isSubCategoryChooserOpen && ( -
-
-
-
Selected subcategory
-
{selectedSubCategory.name}
-
- Final category path: {selectedRoot.name} / {selectedSubCategory.name} -
-
- -
-
- )} - - {(!selectedSubCategory || isSubCategoryChooserOpen) && ( -
-
- - setSubCategorySearch(e.target.value)} - placeholder="Search subcategories…" - className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30" +
+ { e.currentTarget.style.display = 'none' }} />
- {sortedFilteredSubCategories.length === 0 && ( -

No subcategories match “{subCategorySearch}”

- )} -
+
+
{ct.name}
+
{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}
+
+
+ {isActive ? 'Selected' : 'Open'} +
+ + ) + })} +
+ )} + + {metadataErrors.contentType &&

{metadataErrors.contentType}

} +
+ + {/* ── Category ── */} + {selectedContentType && ( + <> +
+
+
+

Category

+ {filteredCategoryTree.length} available +
+ + {selectedRoot && !isCategoryChooserOpen && ( +
+
+
+
Selected
+
{selectedRoot.name}
+
+ {subCategories.length > 0 + ? `${subCategories.length} subcategories available` + : 'No subcategory required'} +
+
+ +
+
+ )} + + {(!selectedRoot || isCategoryChooserOpen) && ( +
+
+ + setCategorySearch(e.target.value)} + placeholder="Search categories…" + className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30" + /> +
+ {sortedFilteredCategories.length === 0 && ( +

No categories match “{categorySearch}”

+ )} +
+ {sortedFilteredCategories.map((cat) => { + const isActive = String(metadata.rootCategoryId || '') === String(cat.id) + const childCount = cat.children?.length || 0 + return ( + + ) + })} +
+
+ )} + + {metadataErrors.category &&

{metadataErrors.category}

} +
+ + )} + + {/* ── Subcategory ── */} + {selectedRoot && subCategories.length > 0 && ( + <> +
+
+
+

Subcategory

+ {subCategories.length} available +
+ + {!metadata.subCategoryId && requiresSubCategory && ( +
+ Subcategory still needs to be selected. +
+ )} + + {selectedSubCategory && !isSubCategoryChooserOpen && ( +
+
+
+
Selected
+
{selectedSubCategory.name}
+
+ Path: {selectedRoot.name} / {selectedSubCategory.name} +
+
+ +
+
+ )} + + {(!selectedSubCategory || isSubCategoryChooserOpen) && ( +
+
+ + setSubCategorySearch(e.target.value)} + placeholder="Search subcategories…" + className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30" + /> +
+ {sortedFilteredSubCategories.length === 0 && ( +

No subcategories match “{subCategorySearch}”

+ )} +
{sortedFilteredSubCategories.map((sub) => { const isActive = String(metadata.subCategoryId || '') === String(sub.id) return ( @@ -424,46 +426,32 @@ export default function Step2Details({ >
-
+
{sub.name}
-
- Subcategory option +
+ Subcategory
- + {isActive ? 'Selected' : 'Choose'}
) })} -
- )} -
- )} - - {selectedRoot && subCategories.length === 0 && ( -
- {selectedRoot.name} does not have subcategories. Selecting it is enough. -
- )} -
+
+ )} +
+ )} - {metadataErrors.category &&

{metadataErrors.category}

} + {selectedRoot && subCategories.length === 0 && selectedRoot && ( +
+ {selectedRoot.name} has no subcategories — selecting it is enough. +
+ )}
{/* Title, tags, description, rights */} @@ -472,6 +460,11 @@ export default function Step2Details({ metadata={metadata} suggestedTags={suggestedTags} errors={metadataErrors} + publishMode={publishMode} + scheduledAt={scheduledAt} + timezone={timezone} + onPublishModeChange={onPublishModeChange} + onScheduleAt={onScheduleAt} onChangeTitle={onChangeTitle} onChangeTags={onChangeTags} onChangeDescription={onChangeDescription} diff --git a/resources/js/components/upload/steps/Step3Publish.jsx b/resources/js/components/upload/steps/Step3Publish.jsx index c6a9770f..8bd10f6e 100644 --- a/resources/js/components/upload/steps/Step3Publish.jsx +++ b/resources/js/components/upload/steps/Step3Publish.jsx @@ -56,6 +56,7 @@ export default function Step3Publish({ scheduledAt = null, timezone = null, visibility = 'public', + onVisibilityChange, // Category tree (for label lookup) allRootCategoryOptions = [], filteredCategoryTree = [], @@ -162,7 +163,45 @@ export default function Step3Publish({
- {/* Publish summary: visibility + schedule */} + {/* ── Visibility selector ────────────────────────────────────────── */} +
+

Visibility

+
+ {[ + { 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 ( + + ) + })} +
+
+ + {/* Publish summary: schedule info */}
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'} diff --git a/resources/views/gallery/_browse_nav.blade.php b/resources/views/gallery/_browse_nav.blade.php index 7e941c05..0322328b 100644 --- a/resources/views/gallery/_browse_nav.blade.php +++ b/resources/views/gallery/_browse_nav.blade.php @@ -7,14 +7,28 @@ @php $active = $section ?? 'artworks'; $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([ 'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'], - 'photography' => ['label' => 'Photography', 'icon' => 'fa-camera', 'href' => '/photography'], - 'wallpapers' => ['label' => 'Wallpapers', 'icon' => 'fa-desktop', 'href' => '/wallpapers'], - 'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'], - 'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'], - ]); + ])->merge( + $contentTypes->mapWithKeys(function ($type) use ($iconMap) { + $slug = strtolower((string) ($type->slug ?? '')); + + return [$slug => [ + 'label' => $type->name, + 'icon' => $iconMap[$slug] ?? 'fa-folder-open', + 'href' => $type->url ?? ('/' . $slug), + ]]; + }) + ); if ($includeTags) { $sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']); diff --git a/resources/views/gallery/index.blade.php b/resources/views/gallery/index.blade.php index a7daba3f..13bf64f6 100644 --- a/resources/views/gallery/index.blade.php +++ b/resources/views/gallery/index.blade.php @@ -113,6 +113,7 @@ 'photography' => 'fa-camera', 'wallpapers' => 'fa-desktop', 'skins' => 'fa-layer-group', + 'digital-art' => 'fa-palette', 'other' => 'fa-folder-open', 'tags' => 'fa-tags', ]; @@ -130,20 +131,14 @@ $headerBreadcrumbs = collect(); if (($gallery_type ?? null) === 'browse') { - $headerBreadcrumbs = 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; - } + $headerBreadcrumbs = $breadcrumbs ?? collect(); } elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) { $headerBreadcrumbs = $breadcrumbs; + } elseif (isset($contentType) && $contentType) { + $headerBreadcrumbs = collect([ + (object) ['name' => 'Explore', 'url' => '/browse'], + (object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)], + ]); } @endphp @@ -426,95 +421,6 @@
@endsection -@push('head') - -@endpush - @push('scripts') @vite('resources/js/entry-masonry-gallery.jsx') @vite('resources/js/entry-pill-carousel.jsx') diff --git a/resources/views/layouts/nova.blade.php b/resources/views/layouts/nova.blade.php index 16d35396..82daaf3b 100644 --- a/resources/views/layouts/nova.blade.php +++ b/resources/views/layouts/nova.blade.php @@ -49,33 +49,6 @@ @vite($novaViteEntries) - @stack('head') @if($deferToolbarSearch) diff --git a/routes/legacy.php b/routes/legacy.php index 3ad9ccde..119f81f4 100644 --- a/routes/legacy.php +++ b/routes/legacy.php @@ -33,7 +33,7 @@ Route::redirect('/browse-categories', '/categories', 301)->name('browse.categori // /Skins/BrowserBob/210 // /Skins/BrowserBob/sdsdsdsd/210 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', '[^/]+(?:/[^/]+)*') ->whereNumber('id') ->name('legacy.category.short'); diff --git a/routes/web.php b/routes/web.php index 0140c743..e1042747 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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('/memebers', fn () => redirect()->route('creators.top', request()->query(), 301))->name('memebers.redirect'); Route::get('/{type}', [ExploreController::class, 'byType']) - ->where('type', 'artworks|wallpapers|skins|photography|other') + ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other') ->name('type'); 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') ->name('type.mode'); }); @@ -140,10 +140,10 @@ Route::middleware('throttle:60,1')->group(function () { Route::prefix('rss/explore')->name('rss.explore.')->group(function () { Route::get('/{type}', [ExploreFeedController::class, 'byType']) - ->where('type', 'artworks|wallpapers|skins|photography|other') + ->where('type', 'artworks|wallpapers|skins|photography|digital-art|other') ->name('type'); 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') ->name('type.mode'); }); @@ -551,7 +551,7 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])- // ── UPLOAD ──────────────────────────────────────────────────────────────────── Route::middleware(['auth', 'ensure.onboarding.complete'])->group(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 [ 'id' => $ct->id, 'name' => $ct->name, @@ -580,7 +580,7 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->group(function () { })->name('upload'); 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 [ 'id' => $ct->id, 'name' => $ct->name, @@ -666,12 +666,12 @@ Route::bind('artwork', function ($value) { }); Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork']) - ->where('contentTypeSlug', 'photography|wallpapers|skins|other') + ->where('contentTypeSlug', 'photography|wallpapers|skins|other|digital-art') ->where('categoryPath', '[^/]+(?:/[^/]+)*') ->name('artworks.show'); Route::get('/{contentTypeSlug}/{path?}', [BrowseGalleryController::class, 'content']) - ->where('contentTypeSlug', 'photography|wallpapers|skins|other') + ->where('contentTypeSlug', 'photography|wallpapers|skins|other|digital-art') ->where('path', '.*') ->name('content.route'); diff --git a/sync.sh b/sync.sh index 9c6eb0b4..85cb8308 100644 --- a/sync.sh +++ b/sync.sh @@ -1,8 +1,8 @@ #!/bin/bash localFolder='/mnt/d/Sites/Skinbase26/' -remoteFolder='/opt/www/virtual/SkinbaseDev/' -remoteServer='klevze@server3.klevze.si' +remoteFolder='/opt/www/virtual/SkinbaseNova/' +remoteServer='klevze@nastja.klevze.si' rsync -avz \ --chmod=D755,F644 \