diff --git a/app/Console/Commands/AvatarsMigrate.php b/app/Console/Commands/AvatarsMigrate.php index 576e200d..cd722b63 100644 --- a/app/Console/Commands/AvatarsMigrate.php +++ b/app/Console/Commands/AvatarsMigrate.php @@ -21,6 +21,7 @@ class AvatarsMigrate extends Command {--force : Overwrite existing migrated avatars} {--remove-legacy : Remove legacy files after successful migration} {--path=public/files/usericons : Legacy path to scan} + {--user-id= : Only migrate a single user by ID} '; /** @@ -54,8 +55,9 @@ class AvatarsMigrate extends Command $force = $this->option('force'); $removeLegacy = $this->option('remove-legacy'); $legacyPath = base_path($this->option('path')); + $userId = $this->option('user-id') ? (int) $this->option('user-id') : null; - $this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '')); + $this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : '')); // Detect processing backend: Intervention preferred, GD fallback $useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic'); @@ -65,7 +67,12 @@ class AvatarsMigrate extends Command $bar = null; - User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) { + $query = User::with('profile'); + if ($userId) { + $query->where('id', $userId); + } + + $query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) { foreach ($users as $user) { /** @var UserProfile|null $profile */ $profile = $user->profile; diff --git a/app/Console/Commands/ImportLegacyUsers.php b/app/Console/Commands/ImportLegacyUsers.php index eb1617ed..115a27be 100644 --- a/app/Console/Commands/ImportLegacyUsers.php +++ b/app/Console/Commands/ImportLegacyUsers.php @@ -2,7 +2,6 @@ namespace App\Console\Commands; -use App\Models\User; use App\Support\UsernamePolicy; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -12,39 +11,70 @@ use Illuminate\Support\Str; class ImportLegacyUsers extends Command { - protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users}'; + protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}'; protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec'; - protected array $usedUsernames = []; - protected array $usedEmails = []; protected string $migrationLogPath; + /** @var array Legacy user IDs that qualify for import */ + protected array $activeUserIds = []; public function handle(): int { $this->migrationLogPath = storage_path('logs/username_migration.log'); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND); - $this->usedUsernames = User::pluck('username', 'username')->filter()->all(); - $this->usedEmails = User::pluck('email', 'email')->filter()->all(); + // Build the set of legacy user IDs that have any meaningful activity. + // Users outside this set will be skipped (or deleted from the new DB if already imported). + $this->activeUserIds = $this->buildActiveUserIds(); + $this->info('Active legacy users (uploads / comments / forum): ' . count($this->activeUserIds)); - $chunk = (int) $this->option('chunk'); - $imported = 0; - $skipped = 0; + $chunk = (int) $this->option('chunk'); + $dryRun = (bool) $this->option('dry-run'); + $imported = 0; + $skipped = 0; + $purged = 0; - if (! DB::getPdo()) { + if (! DB::connection('legacy')->getPdo()) { $this->error('Legacy DB connection "legacy" is not configured or reachable.'); return self::FAILURE; } - DB::table('users') - ->chunkById($chunk, function ($rows) use (&$imported, &$skipped) { + DB::connection('legacy')->table('users') + ->chunkById($chunk, function ($rows) use (&$imported, &$skipped, &$purged, $dryRun) { $ids = $rows->pluck('user_id')->all(); - $stats = DB::table('users_statistics') + $stats = DB::connection('legacy')->table('users_statistics') ->whereIn('user_id', $ids) ->get() ->keyBy('user_id'); foreach ($rows as $row) { + $legacyId = (int) $row->user_id; + + // ── Inactive user: no uploads, no comments, no forum activity ── + if (! isset($this->activeUserIds[$legacyId])) { + // If already imported into the new DB, purge it. + $existsInNew = DB::table('users')->where('id', $legacyId)->exists(); + if ($existsInNew) { + if ($dryRun) { + $this->warn("[dry] Would DELETE inactive user_id={$legacyId} from new DB"); + } else { + $this->purgeNewUser($legacyId); + $this->warn("[purge] Deleted inactive user_id={$legacyId} from new DB"); + $purged++; + } + } else { + $this->line("[skip] user_id={$legacyId} no activity — skipping"); + } + $skipped++; + continue; + } + + if ($dryRun) { + $this->line("[dry] Would import user_id={$legacyId}"); + $imported++; + continue; + } + try { $this->importRow($row, $stats[$row->user_id] ?? null); $imported++; @@ -55,18 +85,59 @@ class ImportLegacyUsers extends Command } }, 'user_id'); - $this->info("Imported: {$imported}, Skipped: {$skipped}"); + $this->info("Imported: {$imported}, Skipped: {$skipped}, Purged: {$purged}"); return self::SUCCESS; } + /** + * Build a lookup array of legacy user IDs that qualify for import: + * — uploaded at least one artwork (users_statistics.uploads > 0) + * — posted at least one artwork comment (artworks_comments.user_id) + * — created or posted to a forum thread (forum_topics / forum_posts) + * + * @return array + */ + protected function buildActiveUserIds(): array + { + $rows = DB::connection('legacy')->select(" + SELECT DISTINCT user_id FROM users_statistics WHERE uploads > 0 + UNION + SELECT DISTINCT user_id FROM artworks_comments WHERE user_id > 0 + UNION + SELECT DISTINCT user_id FROM forum_posts WHERE user_id > 0 + UNION + SELECT DISTINCT user_id FROM forum_topics WHERE user_id > 0 + "); + + $map = []; + foreach ($rows as $r) { + $map[(int) $r->user_id] = true; + } + return $map; + } + + /** + * Remove all new-DB records for a given legacy user ID. + * Covers: users, user_profiles, user_statistics, username_redirects. + */ + protected function purgeNewUser(int $userId): void + { + DB::transaction(function () use ($userId) { + DB::table('username_redirects')->where('user_id', $userId)->delete(); + DB::table('user_statistics')->where('user_id', $userId)->delete(); + DB::table('user_profiles')->where('user_id', $userId)->delete(); + DB::table('users')->where('id', $userId)->delete(); + }); + } + protected function importRow($row, $statRow = null): void { $legacyId = (int) $row->user_id; - $rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId)); - $baseUsername = $this->sanitizeUsername($rawLegacyUsername); - $username = $this->uniqueUsername($baseUsername); - $normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername); + // Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB). + $username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId))); + + $normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? '')); if ($normalizedLegacy !== $username) { @file_put_contents( $this->migrationLogPath, @@ -75,7 +146,9 @@ class ImportLegacyUsers extends Command ); } - $email = $this->prepareEmail($row->email ?? null, $username); + // Use the real legacy email; only synthesise a placeholder when missing. + $rawEmail = $row->email ? strtolower(trim($row->email)) : null; + $email = $rawEmail ?: ($this->sanitizeEmailLocal($username) . '@users.skinbase.org'); $legacyPassword = $row->password2 ?: $row->password ?: null; @@ -100,49 +173,63 @@ class ImportLegacyUsers extends Command DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { $now = now(); + $alreadyExists = DB::table('users')->where('id', $legacyId)->exists(); - DB::table('users')->insert([ - 'id' => $legacyId, - 'username' => $username, - 'username_changed_at' => now(), - 'name' => $row->real_name ?: $username, - 'email' => $email, - 'password' => $passwordHash, - 'is_active' => (int) ($row->active ?? 1) === 1, + // All fields synced from legacy on every run + $sharedFields = [ + 'username' => $username, + 'username_changed_at' => $now, + 'name' => $row->real_name ?: $username, + 'email' => $email, + 'is_active' => (int) ($row->active ?? 1) === 1, 'needs_password_reset' => true, - 'role' => 'user', + 'role' => 'user', 'legacy_password_algo' => null, - 'last_visit_at' => $row->LastVisit ?: null, - 'created_at' => $row->joinDate ?: $now, - 'updated_at' => $now, - ]); + 'last_visit_at' => $row->LastVisit ?: null, + 'updated_at' => $now, + ]; - DB::table('user_profiles')->insert([ - 'user_id' => $legacyId, - 'about' => $row->about_me ?: $row->description ?: null, - 'avatar' => $row->picture ?: null, - 'cover_image' => $row->cover_art ?: null, - 'country' => $row->country ?: null, - 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, - 'language' => $row->lang ?: null, - 'birthdate' => $row->birth ?: null, - 'gender' => $row->gender ?: 'X', - 'website' => $row->web ?: null, - 'created_at' => $now, - 'updated_at' => $now, - ]); + if ($alreadyExists) { + // Sync all fields from legacy — password is never overwritten on re-runs + // (unless --force-reset-all was passed, in which case the caller handles it + // separately outside this transaction). + DB::table('users')->where('id', $legacyId)->update($sharedFields); + } else { + DB::table('users')->insert(array_merge($sharedFields, [ + 'id' => $legacyId, + 'password' => $passwordHash, + 'created_at' => $row->joinDate ?: $now, + ])); + } + + DB::table('user_profiles')->updateOrInsert( + ['user_id' => $legacyId], + [ + 'about' => $row->about_me ?: $row->description ?: null, + 'avatar_legacy' => $row->picture ?: null, + 'cover_image' => $row->cover_art ?: null, + 'country' => $row->country ?: null, + 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, + 'language' => $row->lang ?: null, + 'birthdate' => $row->birth ?: null, + 'gender' => $row->gender ?: 'X', + 'website' => $row->web ?: null, + 'updated_at' => $now, + ] + ); // Do not duplicate `website` into `user_social_links` — keep canonical site in `user_profiles.website`. - DB::table('user_statistics')->insert([ - 'user_id' => $legacyId, - 'uploads' => $uploads, - 'downloads' => $downloads, - 'pageviews' => $pageviews, - 'awards' => $awards, - 'created_at' => $now, - 'updated_at' => $now, - ]); + DB::table('user_statistics')->updateOrInsert( + ['user_id' => $legacyId], + [ + 'uploads' => $uploads, + 'downloads' => $downloads, + 'pageviews' => $pageviews, + 'awards' => $awards, + 'updated_at' => $now, + ] + ); if (Schema::hasTable('username_redirects')) { $old = UsernamePolicy::normalize((string) ($row->uname ?? '')); @@ -178,37 +265,6 @@ class ImportLegacyUsers extends Command return UsernamePolicy::sanitizeLegacy($username); } - protected function uniqueUsername(string $base): string - { - $name = UsernamePolicy::uniqueCandidate($base); - $this->usedUsernames[$name] = $name; - return $name; - } - - protected function prepareEmail(?string $legacyEmail, string $username): string - { - $legacyEmail = $legacyEmail ? strtolower(trim($legacyEmail)) : null; - $baseLocal = $this->sanitizeEmailLocal($username); - $domain = 'users.skinbase.org'; - - $email = $legacyEmail ?: ($baseLocal . '@' . $domain); - $email = $this->uniqueEmail($email, $baseLocal, $domain); - return $email; - } - - protected function uniqueEmail(string $email, string $baseLocal, string $domain): string - { - $i = 1; - $local = explode('@', $email)[0]; - $current = $email; - while (isset($this->usedEmails[$current]) || DB::table('users')->where('email', $current)->exists()) { - $current = $local . $i . '@' . $domain; - $i++; - } - $this->usedEmails[$current] = $current; - return $current; - } - protected function sanitizeEmailLocal(string $value): string { $local = strtolower(trim($value)); diff --git a/app/Http/Controllers/Api/ArtworkNavigationController.php b/app/Http/Controllers/Api/ArtworkNavigationController.php index 88432473..7330a9b5 100644 --- a/app/Http/Controllers/Api/ArtworkNavigationController.php +++ b/app/Http/Controllers/Api/ArtworkNavigationController.php @@ -36,6 +36,14 @@ class ArtworkNavigationController extends Controller $prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first(); $next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first(); + // Infinite loop: wrap around when reaching the first or last artwork + if (! $prev) { + $prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first(); + } + if (! $next) { + $next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first(); + } + $prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null; $nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null; @@ -56,7 +64,7 @@ class ArtworkNavigationController extends Controller */ public function pageData(int $id): JsonResponse { - $artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats']) + $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats']) ->published() ->find($id); diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index 6f0b06fe..ca82709d 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -18,7 +18,7 @@ final class ArtworkPageController extends Controller { public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse { - $artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats']) + $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats']) ->where('id', $id) ->public() ->published() diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index c74d0de5..e7f35e29 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Web; use App\Http\Controllers\Controller; +use App\Models\ContentType; use App\Models\Tag; use App\Services\ArtworkSearchService; use Illuminate\Http\Request; @@ -40,22 +41,29 @@ final class TagController extends Controller // Scout returns bare Eloquent models; without this, each card triggers N+1 queries. $artworks->getCollection()->loadMissing(['user.profile']); - // OG image: first result's thumbnail - $ogImage = null; - if ($artworks->count() > 0) { - $first = $artworks->getCollection()->first(); - $ogImage = $first?->thumbUrl('md'); - } + // Sidebar: content type links (same as browse gallery) + $mainCategories = ContentType::orderBy('id')->get(['name', 'slug']) + ->map(fn ($type) => (object) [ + 'id' => $type->id, + 'name' => $type->name, + 'slug' => $type->slug, + 'url' => '/' . strtolower($type->slug), + ]); - return view('tags.show', [ - 'tag' => $tag, - 'artworks' => $artworks, - 'sort' => $sort, - 'ogImage' => $ogImage, - 'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase', + return view('gallery.index', [ + 'gallery_type' => 'tag', + 'mainCategories' => $mainCategories, + 'subcategories' => collect(), + 'contentType' => null, + 'category' => null, + 'artworks' => $artworks, + 'hero_title' => '#' . $tag->name, + 'hero_description'=> 'All artworks tagged "' . e($tag->name) . '".', + 'breadcrumbs' => collect(), + 'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase', 'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.', - 'page_canonical' => route('tags.show', $tag->slug), - 'page_robots' => 'index,follow', + 'page_canonical' => route('tags.show', $tag->slug), + 'page_robots' => 'index,follow', ]); } } diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index d80ea230..ce4dc8df 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -106,6 +106,7 @@ class ArtworkResource extends JsonResource 'slug' => (string) $category->slug, 'name' => (string) $category->name, 'content_type_slug' => (string) ($category->contentType?->slug ?? ''), + 'url' => $category->contentType ? $category->url : null, ])->values(), 'tags' => $this->tags->map(fn ($tag) => [ 'id' => (int) $tag->id, diff --git a/public/legacy/assets/js/application.js b/public/legacy/assets/js/application.js index 18e9214e..13daa4c2 100644 --- a/public/legacy/assets/js/application.js +++ b/public/legacy/assets/js/application.js @@ -162,16 +162,16 @@ function sidebarHeight() { /* Sidebar Statistics */ if ($.fn.sparkline) { - sparkline1_color = '#159077'; - sparkline2_color = '#00699e'; + sparkline1_color = '#159077'; + sparkline2_color = '#00699e'; sparkline3_color = '#9e494e'; - if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} + if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} if($.cookie('style-color') == 'red') { sparkline1_color = '#121212'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#E0A832';} if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';} if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';} - if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} - + if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} + /* Sparklines can also take their values from the first argument passed to the sparkline() function */ var myvalues1 = [13, 14, 16, 15, 11, 14, 20, 14, 12, 16, 11, 17, 19, 16]; var myvalues2 = [14, 17, 16, 12, 18, 16, 22, 15, 14, 17, 11, 18, 11, 12]; @@ -261,7 +261,7 @@ function chatSidebar() { var setColor = function (color) { var color_ = 'color-'+color; $('#theme-color').attr("href", "assets/css/colors/" + color_ + ".css"); - if ($.cookie) { + if ($.cookie) { $.cookie('style-color', color); } } @@ -274,11 +274,11 @@ $('.theme-color').click(function (e) { $('.theme-color').parent().removeClass("c-white w-600"); $(this).parent().addClass("c-white w-600"); - if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} + if($.cookie('style-color') == 'dark') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} if($.cookie('style-color') == 'red') { sparkline1_color = '#E0A832'; sparkline2_color = '#4AB2F8'; sparkline3_color = '#121212';} if($.cookie('style-color') == 'blue') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';} if($.cookie('style-color') == 'green') { sparkline1_color = '#E0A832'; sparkline2_color = '#D9534F'; sparkline3_color = '#121212';} - if($.cookie('style-color') == 'cafe') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} + if($.cookie('style-color') == 'cafe') { sparkline1_color = '#159077'; sparkline2_color = '#00699e'; sparkline3_color = '#9e494e';} /* We update Sparkline colors */ $('.dynamicbar1').sparkline(myvalues1, {type: 'bar', barColor: sparkline1_color, barWidth: 4, barSpacing: 1, height: '28px'}); @@ -448,12 +448,12 @@ if ($('.icon-validation').length && $.fn.parsley) { $(this).parsley().subscribe('parsley:field:success', function (formInstance) { formInstance.$element.prev().removeClass('fa-exclamation c-red').addClass('fa-check c-green'); - + }); $(this).parsley().subscribe('parsley:field:error', function (formInstance) { formInstance.$element.prev().removeClass('fa-check c-green').addClass('fa-exclamation c-red'); - + }); }); @@ -690,7 +690,7 @@ if ($('.gallery').length && $.fn.mixItUp) { $(this).mixItUp({ animation: { - enable: false + enable: false }, callbacks: { onMixLoad: function(){ @@ -698,7 +698,7 @@ if ($('.gallery').length && $.fn.mixItUp) { $(this).mixItUp('setOptions', { animation: { enable: true, - effects: "fade", + effects: "fade", }, }); $(window).bind("load", function() { @@ -751,4 +751,4 @@ $(window).bind('resize', function (e) { tableResponsive(); }, 250); }); -}); \ No newline at end of file +}); diff --git a/resources/js/components/artwork/ArtworkDescription.jsx b/resources/js/components/artwork/ArtworkDescription.jsx index 99943ca0..75753595 100644 --- a/resources/js/components/artwork/ArtworkDescription.jsx +++ b/resources/js/components/artwork/ArtworkDescription.jsx @@ -49,12 +49,15 @@ function renderMarkdownSafe(text) { export default function ArtworkDescription({ artwork }) { const [expanded, setExpanded] = useState(false) const content = (artwork?.description || '').trim() - - if (content.length === 0) return null - const collapsed = content.length > COLLAPSE_AT && !expanded const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}…` : content - const rendered = useMemo(() => renderMarkdownSafe(visibleText), [visibleText]) + // useMemo must always be called (Rules of Hooks) — guard inside the callback + const rendered = useMemo( + () => (content.length > 0 ? renderMarkdownSafe(visibleText) : null), + [content, visibleText], + ) + + if (content.length === 0) return null return (
diff --git a/resources/js/components/artwork/ArtworkHero.jsx b/resources/js/components/artwork/ArtworkHero.jsx index 02058575..85bb2f8d 100644 --- a/resources/js/components/artwork/ArtworkHero.jsx +++ b/resources/js/components/artwork/ArtworkHero.jsx @@ -24,86 +24,97 @@ export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl,
+ {/* Outer flex row: left arrow | image | right arrow */} +
- {hasRealArtworkImage && ( -
- )} + {/* Prev arrow — outside the picture */} +
+ {hasPrev && ( + + )} +
-
e.key === 'Enter' && onOpenViewer() : undefined} - > - {artwork?.title + {/* Image area */} +
+ {hasRealArtworkImage && ( +
+ )} - {artwork?.title setIsLoaded(true)} - onError={(event) => { - event.currentTarget.src = FALLBACK_LG - }} - /> - - {/* Prev arrow */} - {hasPrev && ( - - )} + {artwork?.title - {/* Next arrow */} - {hasNext && ( - - )} + {artwork?.title setIsLoaded(true)} + onError={(event) => { + event.currentTarget.src = FALLBACK_LG + }} + /> + + {onOpenViewer && ( + + )} +
+ + {hasRealArtworkImage && ( +
+ )} +
+ + {/* Next arrow — outside the picture */} +
+ {hasNext && ( + + )} +
- {onOpenViewer && ( - - )}
- - {hasRealArtworkImage && ( -
- )}
) diff --git a/resources/js/components/artwork/ArtworkTags.jsx b/resources/js/components/artwork/ArtworkTags.jsx index dff7e497..63a60601 100644 --- a/resources/js/components/artwork/ArtworkTags.jsx +++ b/resources/js/components/artwork/ArtworkTags.jsx @@ -4,14 +4,10 @@ export default function ArtworkTags({ artwork }) { const [expanded, setExpanded] = useState(false) const tags = useMemo(() => { - const primaryCategorySlug = artwork?.categories?.[0]?.slug || 'all' - const categories = (artwork?.categories || []).map((category) => ({ key: `cat-${category.id || category.slug}`, label: category.name, - href: category.content_type_slug && category.slug - ? `/browse/${category.content_type_slug}/${category.slug}` - : `/browse/${category.slug || ''}`, + href: category.url || `/${category.content_type_slug}/${category.slug}`, })) const artworkTags = (artwork?.tags || []).map((tag) => ({ diff --git a/resources/views/components/artwork-card.blade.php b/resources/views/components/artwork-card.blade.php index fcdd2175..3279c97d 100644 --- a/resources/views/components/artwork-card.blade.php +++ b/resources/views/components/artwork-card.blade.php @@ -91,9 +91,12 @@ return (int) $fallback; }; - $imgWidth = max(1, $resolveDimension($art->width ?? null, 'width', 800)); - $imgHeight = max(1, $resolveDimension($art->height ?? null, 'height', 600)); - $imgAspectRatio = $imgWidth . ' / ' . $imgHeight; + // Use stored dimensions when available; otherwise leave ratio unconstrained + // so the thumbnail displays at its natural proportions (no 1:1 or 16:9 forcing). + $hasDimensions = ($art->width ?? 0) > 0 && ($art->height ?? 0) > 0; + $imgWidth = $hasDimensions ? max(1, $resolveDimension($art->width, 'width', 0)) : null; + $imgHeight = $hasDimensions ? max(1, $resolveDimension($art->height, 'height', 0)) : null; + $imgAspectRatio = $hasDimensions ? ($imgWidth . ' / ' . $imgHeight) : null; $contentUrl = $imgSrc; $cardUrl = (string) ($art->url ?? ''); @@ -134,8 +137,8 @@
{{ $category }}
@endif -
-
+
+
@@ -148,9 +151,9 @@ @if($fetchpriority) fetchpriority="{{ $fetchpriority }}" @endif @if($loading !== 'eager') data-blur-preview @endif alt="{{ e($title) }}" - width="{{ $imgWidth }}" - height="{{ $imgHeight }}" - class="h-full w-full object-cover transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]" + @if($imgWidth) width="{{ $imgWidth }}" @endif + @if($imgHeight) height="{{ $imgHeight }}" @endif + class="{{ $imgAspectRatio ? 'h-full w-full object-cover' : 'w-full h-auto' }} transition-[transform,filter] duration-300 ease-out group-hover:scale-[1.04]" itemprop="thumbnailUrl" /> diff --git a/resources/views/layouts/nova/toolbar.blade.php b/resources/views/layouts/nova/toolbar.blade.php index 653c59cd..a02ec1f4 100644 --- a/resources/views/layouts/nova/toolbar.blade.php +++ b/resources/views/layouts/nova/toolbar.blade.php @@ -49,7 +49,7 @@
diff --git a/resources/views/legacy/[#6223] Red Cloud XP.txt b/resources/views/legacy/[#6223] Red Cloud XP.txt new file mode 100644 index 00000000..7c1bcc42 --- /dev/null +++ b/resources/views/legacy/[#6223] Red Cloud XP.txt @@ -0,0 +1,6 @@ + [#6223] Red Cloud XP + → windows-logo, retro-computing, red-dominant-colour, dark-mood, pixelated-graphics, digital-art, grunge-texture, office-shortcuts-menu, 90s-aesthetic, chromatic-aberration, high-contrast, textured-background + [#6225] Helping Hand zoomers (part 1) + → desktop screenshot, computer icons, windows interface, digital-art, blue-grey tones, flat design, minimalist-style, iconography, organized layout, technical illustration, screen capture, system icons + [#6226] Helping Hand zoomers (part 2) +PS D:\Sites\Skinbase26> diff --git a/resources/views/web/home/uploads.blade.php b/resources/views/web/home/uploads.blade.php index 114cd85c..9b3b83c4 100644 --- a/resources/views/web/home/uploads.blade.php +++ b/resources/views/web/home/uploads.blade.php @@ -20,7 +20,7 @@ {{-- Latest uploads grid — use same Nova gallery layout as /browse --}}
-
+
@forelse($latestUploads as $upload) @empty @@ -53,10 +53,10 @@ [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (min-width: 1024px) { - [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(4, minmax(0, 1fr)); } + [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } } @media (min-width: 2600px) { - [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); } + [data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); } } [data-nova-gallery].is-enhanced [data-gallery-grid] > .nova-card { margin: 0 !important; } [data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; }