From 48e2055b6a7a96e3f6fec59c5c12af11b287e359 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 21 Feb 2026 21:39:23 +0100 Subject: [PATCH] gallery fix --- app/Http/Controllers/Api/BrowseController.php | 30 ++- .../Web/BrowseGalleryController.php | 100 +++++++- public/js/custom.js | 59 +++-- public/js/legacy-gallery-init.js | 53 +++-- public/js/sbcode_original.js | 215 ++++++++++-------- public/legacy/js/custom.js | 59 +++-- public/legacy/js/legacy-gallery-init.js | 53 +++-- public/legacy/js/sbcode_original.js | 215 ++++++++++-------- resources/css/nova-grid.css | 149 ++++++++++++ resources/js/nova.js | 28 +++ .../views/components/artwork-card.blade.php | 183 +++++++++++++++ .../skeleton/artwork-card.blade.php | 8 + resources/views/dashboard/favorites.blade.php | 60 ++--- resources/views/gallery/index.blade.php | 14 +- resources/views/layouts/nova.blade.php | 16 +- resources/views/legacy/profile.blade.php | 21 +- resources/views/web/home/uploads.blade.php | 11 +- .../web/partials/_artwork_card.blade.php | 129 +---------- tests/Feature/BrowseApiTest.php | 132 ++++++++++- tests/Feature/DashboardFavoritesTest.php | 10 +- 20 files changed, 1064 insertions(+), 481 deletions(-) create mode 100644 resources/css/nova-grid.css create mode 100644 resources/views/components/artwork-card.blade.php create mode 100644 resources/views/components/skeleton/artwork-card.blade.php diff --git a/app/Http/Controllers/Api/BrowseController.php b/app/Http/Controllers/Api/BrowseController.php index e68e4136..4c45cd4b 100644 --- a/app/Http/Controllers/Api/BrowseController.php +++ b/app/Http/Controllers/Api/BrowseController.php @@ -23,10 +23,14 @@ class BrowseController extends Controller */ public function index(Request $request) { - $perPage = min(max((int) $request->get('per_page', 24), 1), 100); + $perPage = $this->resolvePerPage($request); $sort = (string) $request->get('sort', 'latest'); $paginator = $this->service->browsePublicArtworks($perPage, $sort); + $paginator->appends([ + 'limit' => $perPage, + 'sort' => $sort, + ]); return ArtworkListResource::collection($paginator); } @@ -37,7 +41,7 @@ class BrowseController extends Controller */ public function byContentType(Request $request, string $contentTypeSlug) { - $perPage = min(max((int) $request->get('per_page', 24), 1), 100); + $perPage = $this->resolvePerPage($request); $sort = (string) $request->get('sort', 'latest'); try { @@ -46,6 +50,11 @@ class BrowseController extends Controller abort(404); } + $paginator->appends([ + 'limit' => $perPage, + 'sort' => $sort, + ]); + if ($paginator->count() === 0) { return response()->json(['message' => 'Gone'], 410); } @@ -59,7 +68,7 @@ class BrowseController extends Controller */ public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath) { - $perPage = min(max((int) $request->get('per_page', 24), 1), 100); + $perPage = $this->resolvePerPage($request); $sort = (string) $request->get('sort', 'latest'); $slugs = array_merge([ @@ -72,10 +81,25 @@ class BrowseController extends Controller abort(404); } + $paginator->appends([ + 'limit' => $perPage, + 'sort' => $sort, + ]); + if ($paginator->count() === 0) { return response()->json(['message' => 'Gone'], 410); } return ArtworkListResource::collection($paginator); } + + private function resolvePerPage(Request $request): int + { + $limit = (int) $request->query('limit', 0); + $perPage = (int) $request->query('per_page', 0); + + $value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24); + + return min(max($value, 1), 100); + } } diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index 942576fc..02525b9e 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -8,6 +8,8 @@ use App\Models\Artwork; use App\Services\ArtworkService; use Illuminate\Http\Request; use Illuminate\Support\Collection; +use Illuminate\Pagination\AbstractPaginator; +use Illuminate\Pagination\AbstractCursorPaginator; class BrowseGalleryController extends \App\Http\Controllers\Controller { @@ -23,6 +25,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller $perPage = $this->resolvePerPage($request); $artworks = $this->artworks->browsePublicArtworks($perPage, $sort); + $seo = $this->buildPaginationSeo($request, url('/browse'), $artworks); $mainCategories = $this->mainCategories(); @@ -39,7 +42,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller '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', - 'page_canonical' => url('/browse'), + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -64,6 +70,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller $normalizedPath = trim((string) $path, '/'); if ($normalizedPath === '') { $artworks = $this->artworks->getArtworksByContentType($contentSlug, $perPage, $sort); + $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug), $artworks); return view('gallery.index', [ 'gallery_type' => 'content-type', @@ -78,7 +85,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'page_title' => $contentType->name, 'page_meta_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase'), 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', - 'page_canonical' => url('/' . $contentSlug), + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -89,6 +99,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller } $artworks = $this->artworks->getArtworksByCategoryPath(array_merge([$contentSlug], $segments), $perPage, $sort); + $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks); $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); if ($subcategories->isEmpty()) { @@ -116,7 +127,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'page_title' => $category->name, 'page_meta_description' => $category->description ?? ($contentType->name . ' artworks on Skinbase'), 'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography', - 'page_canonical' => url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), + 'page_canonical' => $seo['canonical'], + 'page_rel_prev' => $seo['prev'], + 'page_rel_next' => $seo['next'], + 'page_robots' => 'index,follow', ]); } @@ -180,7 +194,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller private function resolvePerPage(Request $request): int { - $value = (int) $request->query('per_page', 40); + $limit = (int) $request->query('limit', 0); + $perPage = (int) $request->query('per_page', 0); + + $value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 40); return max(12, min($value, 80)); } @@ -198,4 +215,79 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller ]; }); } + + private function buildPaginationSeo(Request $request, string $canonicalBaseUrl, mixed $paginator): array + { + $canonicalQuery = $request->query(); + unset($canonicalQuery['grid']); + if (($canonicalQuery['page'] ?? null) !== null && (int) $canonicalQuery['page'] <= 1) { + unset($canonicalQuery['page']); + } + + $canonical = $canonicalBaseUrl; + if ($canonicalQuery !== []) { + $canonical .= '?' . http_build_query($canonicalQuery); + } + + $prev = null; + $next = null; + + if ($paginator instanceof AbstractPaginator || $paginator instanceof AbstractCursorPaginator) { + $prev = $this->stripQueryParamFromUrl($paginator->previousPageUrl(), 'grid'); + $next = $this->stripQueryParamFromUrl($paginator->nextPageUrl(), 'grid'); + } + + return [ + 'canonical' => $canonical, + 'prev' => $prev, + 'next' => $next, + ]; + } + + private function stripQueryParamFromUrl(?string $url, string $queryParam): ?string + { + if ($url === null || $url === '') { + return null; + } + + $parts = parse_url($url); + if (!is_array($parts)) { + return $url; + } + + $query = []; + if (!empty($parts['query'])) { + parse_str($parts['query'], $query); + unset($query[$queryParam]); + } + + $rebuilt = ''; + if (isset($parts['scheme'])) { + $rebuilt .= $parts['scheme'] . '://'; + } + if (isset($parts['user'])) { + $rebuilt .= $parts['user']; + if (isset($parts['pass'])) { + $rebuilt .= ':' . $parts['pass']; + } + $rebuilt .= '@'; + } + if (isset($parts['host'])) { + $rebuilt .= $parts['host']; + } + if (isset($parts['port'])) { + $rebuilt .= ':' . $parts['port']; + } + $rebuilt .= $parts['path'] ?? ''; + + if ($query !== []) { + $rebuilt .= '?' . http_build_query($query); + } + + if (isset($parts['fragment'])) { + $rebuilt .= '#' . $parts['fragment']; + } + + return $rebuilt; + } } diff --git a/public/js/custom.js b/public/js/custom.js index d3d5f569..14354012 100644 --- a/public/js/custom.js +++ b/public/js/custom.js @@ -44,6 +44,13 @@ $(document).on("change", ".quickThumbShow", function(evt) { }); var numCols = 4; +var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } +})(); $(document).ready(function() { @@ -73,22 +80,28 @@ $(document).ready(function() { $(".photo_frame").css("width", "250px"); } - $container1.isotope({ - masonry: { columnWidth: wc } - }); + if (!GRID_V2_ENABLED) { + $container1.isotope({ + masonry: { columnWidth: wc } + }); + } } - $container1.imagesLoaded( function() { - $container1.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container1.imagesLoaded( function() { + $container1.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + size(); }); - size(); - }); + } - $(window).smartresize(size); + if (!GRID_V2_ENABLED) { + $(window).smartresize(size); + } $(".summernote").summernote(); $(".summernote_lite").summernote({ @@ -101,21 +114,25 @@ $(document).ready(function() { }); var $container = $('.container_gallery'); - $container.imagesLoaded( function(){ - $container.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container.imagesLoaded( function(){ + $container.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + size(); }); - size(); - }); + } var $container = $('.container_news'); - $container.imagesLoaded( function(){ - $container.isotope({ - itemSelector : '.news_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container.imagesLoaded( function(){ + $container.isotope({ + itemSelector : '.news_frame', + layoutMode : 'masonry' + }); }); - }); + } if ($("a[rel^='prettyPhoto']").length > 0) { $("a[rel^='prettyPhoto']").prettyPhoto({theme:'dark_rounded'}); diff --git a/public/js/legacy-gallery-init.js b/public/js/legacy-gallery-init.js index d3b5166f..a7bd7b9b 100644 --- a/public/js/legacy-gallery-init.js +++ b/public/js/legacy-gallery-init.js @@ -6,6 +6,14 @@ (function () { 'use strict'; + var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } + })(); + var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220; var LOAD_TRIGGER_MARGIN = '900px'; @@ -28,11 +36,16 @@ box.classList.remove('is-loading'); return; } + var templateHost = root.querySelector('[data-gallery-skeleton-template]'); + var templateNode = templateHost ? templateHost.firstElementChild : null; box.classList.add('is-loading'); var total = Math.max(4, count || 8); for (var i = 0; i < total; i += 1) { - var sk = document.createElement('div'); - sk.className = 'nova-skeleton-card'; + var sk = templateNode ? templateNode.cloneNode(true) : document.createElement('div'); + if (!templateNode) { + sk.className = 'nova-skeleton-card'; + sk.innerHTML = '
'; + } box.appendChild(sk); } } @@ -131,25 +144,6 @@ var grid = root.querySelector('[data-gallery-grid]'); if (!grid) return; - // loader overlay element (created lazily) - var loader = null; - function ensureLoader() { - if (loader) return loader; - loader = document.createElement('div'); - loader.className = 'nova-loader-overlay'; - var inner = document.createElement('div'); - inner.className = 'nova-loader-spinner'; - loader.appendChild(inner); - // place loader as child of root so it overlays grid area - loader.style.display = 'none'; - root.style.position = root.style.position || ''; - root.appendChild(loader); - return loader; - } - - function showLoader() { var l = ensureLoader(); l.style.display = 'flex'; } - function hideLoader() { if (loader) loader.style.display = 'none'; } - root.classList.add('is-enhanced'); var state = { @@ -159,8 +153,20 @@ }; function relayout() { - waitForImages(grid).then(function () { + // Apply masonry synchronously first — the card already has inline aspect-ratio + // set from image dimensions, so getBoundingClientRect() returns the correct + // reserved height immediately at DOMContentLoaded without waiting for decode. + // This collapses both the is-enhanced class change and span assignment into one + // paint frame, eliminating the visible layout jump (CLS). + if (!GRID_V2_ENABLED) { applyMasonry(root); + } + // Secondary pass after images finish decoding — corrects any lazy-loaded or + // dynamically-appended cards whose heights weren't yet known. + waitForImages(grid).then(function () { + if (!GRID_V2_ENABLED) { + applyMasonry(root); + } applyVirtualizationHints(root); }); } @@ -178,8 +184,6 @@ if (state.loading || state.done || !state.nextUrl) return; state.loading = true; - showLoader(); - var sampleCards = toArray(grid.querySelectorAll('.nova-card')); var skeletonCount = Math.min(12, Math.max(4, sampleCards.length ? sampleCards.slice(-4).length * 2 : 8)); setSkeleton(root, true, skeletonCount); @@ -215,7 +219,6 @@ } finally { state.loading = false; setSkeleton(root, false); - hideLoader(); } } diff --git a/public/js/sbcode_original.js b/public/js/sbcode_original.js index 3f6bbe61..193a87ef 100644 --- a/public/js/sbcode_original.js +++ b/public/js/sbcode_original.js @@ -1,4 +1,4 @@ -var m_html = '



'; +var m_html = '



'; var m_browser=0; var m_browser2=0; var m_browser3=0; @@ -58,7 +58,7 @@ $(document).on("click", ".addFavourites", function() { console.log(id); $.getJSON("/ajax/add_artwork_favourites/" + id, function(data) { }); -}); +}); $(document).on("change", "#root_categories", function() { var id = $("#root_categories option:selected").val(); @@ -118,12 +118,12 @@ $(document).on("click", ".follow_user", function() { $(document).on("change", ".quickThumbShow", function(evt) { - - var preview = $(this).data("preview_id"); + + var preview = $(this).data("preview_id"); var files = evt.target.files; var f = files[0]; var reader = new FileReader(); - + reader.onload = (function(theFile) { return function(e) { fname = (theFile.name); @@ -136,48 +136,59 @@ $(document).on("change", ".quickThumbShow", function(evt) { } }; })(f); - + reader.readAsDataURL(f); }); var numCols = 4; +var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } +})(); function size() { - + NProgress.start(); var $container_photo = $('.container_photo'); var w = $container_photo.width(); var c = Math.floor(w / 250); var wc = parseInt($container_photo.width() / c); - + var r = parseInt(w / c) - 30; //console.log(w, "Cols:" + c, "width: " + r + "px"); $(".photo_frame").css("width", r + "px") - $container_photo.isotope({ - masonry: { columnWidth: c } - }); + if (!GRID_V2_ENABLED) { + $container_photo.isotope({ + masonry: { columnWidth: c } + }); - $container_photo.imagesLoaded( function() { - $container_photo.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' - }); - }); + $container_photo.imagesLoaded( function() { + $container_photo.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + }); - $container_photo.isotope( 'on', 'layoutComplete', function() { - var hgt = $("#artwork_browser").css("height"); - if (hgt < 500) { - hgt = 500; - } - $("#artwork_subcategories").css("height", hgt); - }); + $container_photo.isotope( 'on', 'layoutComplete', function() { + var hgt = $("#artwork_browser").css("height"); + if (hgt < 500) { + hgt = 500; + } + $("#artwork_subcategories").css("height", hgt); + }); + } NProgress.done(); } // End size() -size(); +if (!GRID_V2_ENABLED) { + size(); +} $(document).ready(function() { @@ -186,16 +197,18 @@ $(document).ready(function() { NProgress.start(); $(".scrollContent").mCustomScrollbar(); - + $(".artwork-zoom").magnificPopup({ type: 'image', removalDelay: 3800, mainClass: 'mfp-fade' }); - - $(window).smartresize(size); - //size(); - + + if (!GRID_V2_ENABLED) { + $(window).smartresize(size); + } + //size(); + $(".selectme").select2(); $(".summernote").summernote(); $(".summernote_lite").summernote({ @@ -206,41 +219,43 @@ $(document).ready(function() { ['color', ['color']], ] }); - - var $box_gallery = $('.box_gallery'); - $box_gallery.imagesLoaded( function(){ - $box_gallery.isotope({ - itemSelector : '.img-frame', - layoutMode : 'masonry' - }); - }); - var $container_comments = $(".masonry"); - $container_comments.imagesLoaded( function(){ - $container_comments.isotope({ - itemSelector : '.masonry_item', - layoutMode : 'masonry' - }); - }); - - var $container_news = $('.container_news'); - $container_news.imagesLoaded( function(){ - $container_news.isotope({ - itemSelector : '.news_frame', - layoutMode : 'masonry' - }); - }); - + if (!GRID_V2_ENABLED) { + var $box_gallery = $('.box_gallery'); + $box_gallery.imagesLoaded( function(){ + $box_gallery.isotope({ + itemSelector : '.img-frame', + layoutMode : 'masonry' + }); + }); + + var $container_comments = $(".masonry"); + $container_comments.imagesLoaded( function(){ + $container_comments.isotope({ + itemSelector : '.masonry_item', + layoutMode : 'masonry' + }); + }); + + var $container_news = $('.container_news'); + $container_news.imagesLoaded( function(){ + $container_news.isotope({ + itemSelector : '.news_frame', + layoutMode : 'masonry' + }); + }); + } + if ($("a[rel^='prettyPhoto']").length > 0) { $("a[rel^='prettyPhoto']").prettyPhoto({theme:'dark_rounded'}); } - + $("#sideBarSep").click(function() { if(sbc == 0) { - + $("#sideBarChat").animate({ height: '700px' }, 1000, function(){ @@ -263,13 +278,13 @@ $(document).ready(function() { } }); - + if ($(".followingButton").length > 0) { $(".followingButton").click(function() { $("#showNoticeBox").load("/include/hideNoticeBox.php?show=following"); }); } - + if ($(".streamPost").length > 0) { var remme = false; $(".stream_Remove").click(function() { @@ -280,7 +295,7 @@ $(document).ready(function() { remme = true; } }); - + $(".streamPost").click(function() { $("BODY").scrollTop(0); var wid = $(this).attr("rel"); @@ -292,41 +307,41 @@ $(document).ready(function() { } remme = false; }); - - + + } - + if ($("#js-news").length >0) { $('#js-news').ticker(); } - + if ($("#imageTicker").length >0) { $("#imageTicker").slideDown().newsticker(); } - + if ($(".changeCoverArt").length > 0) { $(".changeCoverArt").click(function() { $("#mywindow").center(); $("#mywindow").show(); }); } - + $(".openwin").fancybox({}); - + $('#ajaxForm').submit(function() { alert('Handler for .submit() called.'); return false; }); - - + + $(".btn_category").fancybox({ 'href' : '/ajax/show-categories', 'width' : 600, 'height' : 200, 'transitionIn' : 'fade' }); - + $(".navbar-fixed-top").autoHidingNavbar({ }); @@ -348,7 +363,7 @@ $(document).ready(function() { if ($("#chat_box").length > 0) { InitChat(); } - + $("#loginMenu span").click(function() { $("#subLoginMenu").toggle(); }); @@ -357,7 +372,7 @@ $(document).ready(function() { $("#browseMenu").click(function(){ //showCategories(); $("#browserMenuList").toggle(); - + }); } @@ -379,7 +394,7 @@ $(document).ready(function() { $("#update_button").html("Attach link"); $("#streamMessage").val("http://"); }); - + $("#publishButton").click(function(){ //event.preventDefault(); var type = $("#streamType").val(); @@ -387,16 +402,16 @@ $(document).ready(function() { //alert("/social/getStreamData.php?type="+type+"&data="+data); $("#streamWork").load("/social/getStreamData.php?type="+type+"&data="+data); $("#streamMessage").val(""); - + }); - + $("#shareBox textarea").elastic(); - + /*if($("#total_msgs").length > 0) { showDownloadCounter(); }*/ - - + + $('textarea.tinymce').tinymce({ // Location of TinyMCE script @@ -429,12 +444,12 @@ function showSubCat(id) { function put_smiley(smiley,outp) { document.forms['newMsg'].comment.value += ' ' + smiley + ' '; document.forms['newMsg'].comment.focus(); - + } function put_smiley_art(smiley,outp) { document.forms['newMsg'].commentText.value += ' ' + smiley + ' '; - document.forms['newMsg'].commentText.focus(); + document.forms['newMsg'].commentText.focus(); } function put_smiley_chat(smiley) { @@ -450,14 +465,14 @@ function put_smiley2(smiley,outp) document.forms['newMsg'].Review.focus(); } -function expw(listID) +function expw(listID) { - if (document.getElementById(listID).style.display=="none") + if (document.getElementById(listID).style.display=="none") { document.getElementById(listID).style.display=""; document.getElementById(listID).style.visibility="visible"; } - else + else { document.getElementById(listID).style.display="none"; document.getElementById(listID).style.visibility="hidden"; @@ -465,7 +480,7 @@ function expw(listID) } -function contw(listID) +function contw(listID) { if (listID.style.display=="show") {listID.style.display="";} else {listID.style.display="none";} @@ -475,7 +490,7 @@ function contw(listID) -function Confirm(link,text) +function Confirm(link,text) { if(confirm(text)) window.location=link @@ -510,7 +525,7 @@ function InitChat() { NProgress.done(); } -function compare(s1, s2) { +function compare(s1, s2) { return s1===s2; } @@ -590,12 +605,12 @@ function ShowPrivateMessage(id) { function ShowPrivateMessageList(box, id) { - + if (box !== 'new') { $("#msgList").html(m_html); $("#msgShow").html(''); } - + if (box == 'new') { //alert ("/privmsg.php?ajax=true&action=msgList&box="+box+"&id=" + id); $("#msgShow").load("/privmsg.php?ajax=true&action=msgList&box="+box+"&id=" + id); @@ -628,7 +643,7 @@ function showDailySkins(datums,x){ document.getElementById('tab-'+selected_tab).style.fontWeight = 'normal'; document.getElementById('tab-'+selected_tab).style.backgroundColor = '#eee'; } - + document.getElementById('tab-'+x).style.fontWeight = 'bold'; document.getElementById('tab-'+x).style.backgroundColor = '#ccc'; selected_tab = x; @@ -675,7 +690,7 @@ function showCategories2(namek) //new Effect.Appear(document.getElementById("displayCategories")); document.getElementById("mainDisplay2").style.visibility = "visible"; updateSkinBrowser("displayCategories2",0,0); - } + } } @@ -690,7 +705,7 @@ function findPosX(obj) { var curleft = 0; if(obj.offsetParent) - while(1) + while(1) { curleft += obj.offsetLeft; if(!obj.offsetParent) @@ -749,37 +764,37 @@ function removeNotice(id,type) { $(this).css({position:'absolute', margin:0, top: (top > 0 ? top : 0)+'px', left: (left > 0 ? left : 0)+'px'}); }); } - }); + }); })(jQuery); (function($,sr){ - + // debouncing function from John Hann // http://unscriptable.com/index.php/2009/03/20/debouncing-javascript-methods/ var debounce = function (func, threshold, execAsap) { var timeout; - + return function debounced () { var obj = this, args = arguments; function delayed () { if (!execAsap) func.apply(obj, args); - timeout = null; + timeout = null; }; - + if (timeout) clearTimeout(timeout); else if (execAsap) func.apply(obj, args); - - timeout = setTimeout(delayed, threshold || 100); + + timeout = setTimeout(delayed, threshold || 100); }; } - // smartresize + // smartresize jQuery.fn[sr] = function(fn){ return fn ? this.bind('resize', debounce(fn)) : this.trigger(sr); }; - + })(jQuery,'smartresize'); @@ -800,4 +815,4 @@ function getCookie(cname) { if (c.indexOf(name) == 0) return c.substring(name.length, c.length); } return ""; -} \ No newline at end of file +} diff --git a/public/legacy/js/custom.js b/public/legacy/js/custom.js index d3d5f569..14354012 100644 --- a/public/legacy/js/custom.js +++ b/public/legacy/js/custom.js @@ -44,6 +44,13 @@ $(document).on("change", ".quickThumbShow", function(evt) { }); var numCols = 4; +var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } +})(); $(document).ready(function() { @@ -73,22 +80,28 @@ $(document).ready(function() { $(".photo_frame").css("width", "250px"); } - $container1.isotope({ - masonry: { columnWidth: wc } - }); + if (!GRID_V2_ENABLED) { + $container1.isotope({ + masonry: { columnWidth: wc } + }); + } } - $container1.imagesLoaded( function() { - $container1.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container1.imagesLoaded( function() { + $container1.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + size(); }); - size(); - }); + } - $(window).smartresize(size); + if (!GRID_V2_ENABLED) { + $(window).smartresize(size); + } $(".summernote").summernote(); $(".summernote_lite").summernote({ @@ -101,21 +114,25 @@ $(document).ready(function() { }); var $container = $('.container_gallery'); - $container.imagesLoaded( function(){ - $container.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container.imagesLoaded( function(){ + $container.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + size(); }); - size(); - }); + } var $container = $('.container_news'); - $container.imagesLoaded( function(){ - $container.isotope({ - itemSelector : '.news_frame', - layoutMode : 'masonry' + if (!GRID_V2_ENABLED) { + $container.imagesLoaded( function(){ + $container.isotope({ + itemSelector : '.news_frame', + layoutMode : 'masonry' + }); }); - }); + } if ($("a[rel^='prettyPhoto']").length > 0) { $("a[rel^='prettyPhoto']").prettyPhoto({theme:'dark_rounded'}); diff --git a/public/legacy/js/legacy-gallery-init.js b/public/legacy/js/legacy-gallery-init.js index d3b5166f..a7bd7b9b 100644 --- a/public/legacy/js/legacy-gallery-init.js +++ b/public/legacy/js/legacy-gallery-init.js @@ -6,6 +6,14 @@ (function () { 'use strict'; + var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } + })(); + var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220; var LOAD_TRIGGER_MARGIN = '900px'; @@ -28,11 +36,16 @@ box.classList.remove('is-loading'); return; } + var templateHost = root.querySelector('[data-gallery-skeleton-template]'); + var templateNode = templateHost ? templateHost.firstElementChild : null; box.classList.add('is-loading'); var total = Math.max(4, count || 8); for (var i = 0; i < total; i += 1) { - var sk = document.createElement('div'); - sk.className = 'nova-skeleton-card'; + var sk = templateNode ? templateNode.cloneNode(true) : document.createElement('div'); + if (!templateNode) { + sk.className = 'nova-skeleton-card'; + sk.innerHTML = '
'; + } box.appendChild(sk); } } @@ -131,25 +144,6 @@ var grid = root.querySelector('[data-gallery-grid]'); if (!grid) return; - // loader overlay element (created lazily) - var loader = null; - function ensureLoader() { - if (loader) return loader; - loader = document.createElement('div'); - loader.className = 'nova-loader-overlay'; - var inner = document.createElement('div'); - inner.className = 'nova-loader-spinner'; - loader.appendChild(inner); - // place loader as child of root so it overlays grid area - loader.style.display = 'none'; - root.style.position = root.style.position || ''; - root.appendChild(loader); - return loader; - } - - function showLoader() { var l = ensureLoader(); l.style.display = 'flex'; } - function hideLoader() { if (loader) loader.style.display = 'none'; } - root.classList.add('is-enhanced'); var state = { @@ -159,8 +153,20 @@ }; function relayout() { - waitForImages(grid).then(function () { + // Apply masonry synchronously first — the card already has inline aspect-ratio + // set from image dimensions, so getBoundingClientRect() returns the correct + // reserved height immediately at DOMContentLoaded without waiting for decode. + // This collapses both the is-enhanced class change and span assignment into one + // paint frame, eliminating the visible layout jump (CLS). + if (!GRID_V2_ENABLED) { applyMasonry(root); + } + // Secondary pass after images finish decoding — corrects any lazy-loaded or + // dynamically-appended cards whose heights weren't yet known. + waitForImages(grid).then(function () { + if (!GRID_V2_ENABLED) { + applyMasonry(root); + } applyVirtualizationHints(root); }); } @@ -178,8 +184,6 @@ if (state.loading || state.done || !state.nextUrl) return; state.loading = true; - showLoader(); - var sampleCards = toArray(grid.querySelectorAll('.nova-card')); var skeletonCount = Math.min(12, Math.max(4, sampleCards.length ? sampleCards.slice(-4).length * 2 : 8)); setSkeleton(root, true, skeletonCount); @@ -215,7 +219,6 @@ } finally { state.loading = false; setSkeleton(root, false); - hideLoader(); } } diff --git a/public/legacy/js/sbcode_original.js b/public/legacy/js/sbcode_original.js index 3f6bbe61..193a87ef 100644 --- a/public/legacy/js/sbcode_original.js +++ b/public/legacy/js/sbcode_original.js @@ -1,4 +1,4 @@ -var m_html = '



'; +var m_html = '



'; var m_browser=0; var m_browser2=0; var m_browser3=0; @@ -58,7 +58,7 @@ $(document).on("click", ".addFavourites", function() { console.log(id); $.getJSON("/ajax/add_artwork_favourites/" + id, function(data) { }); -}); +}); $(document).on("change", "#root_categories", function() { var id = $("#root_categories option:selected").val(); @@ -118,12 +118,12 @@ $(document).on("click", ".follow_user", function() { $(document).on("change", ".quickThumbShow", function(evt) { - - var preview = $(this).data("preview_id"); + + var preview = $(this).data("preview_id"); var files = evt.target.files; var f = files[0]; var reader = new FileReader(); - + reader.onload = (function(theFile) { return function(e) { fname = (theFile.name); @@ -136,48 +136,59 @@ $(document).on("change", ".quickThumbShow", function(evt) { } }; })(f); - + reader.readAsDataURL(f); }); var numCols = 4; +var GRID_V2_ENABLED = (function () { + try { + return new URLSearchParams(window.location.search).get('grid') === 'v2'; + } catch (e) { + return false; + } +})(); function size() { - + NProgress.start(); var $container_photo = $('.container_photo'); var w = $container_photo.width(); var c = Math.floor(w / 250); var wc = parseInt($container_photo.width() / c); - + var r = parseInt(w / c) - 30; //console.log(w, "Cols:" + c, "width: " + r + "px"); $(".photo_frame").css("width", r + "px") - $container_photo.isotope({ - masonry: { columnWidth: c } - }); + if (!GRID_V2_ENABLED) { + $container_photo.isotope({ + masonry: { columnWidth: c } + }); - $container_photo.imagesLoaded( function() { - $container_photo.isotope({ - itemSelector : '.photo_frame', - layoutMode : 'masonry' - }); - }); + $container_photo.imagesLoaded( function() { + $container_photo.isotope({ + itemSelector : '.photo_frame', + layoutMode : 'masonry' + }); + }); - $container_photo.isotope( 'on', 'layoutComplete', function() { - var hgt = $("#artwork_browser").css("height"); - if (hgt < 500) { - hgt = 500; - } - $("#artwork_subcategories").css("height", hgt); - }); + $container_photo.isotope( 'on', 'layoutComplete', function() { + var hgt = $("#artwork_browser").css("height"); + if (hgt < 500) { + hgt = 500; + } + $("#artwork_subcategories").css("height", hgt); + }); + } NProgress.done(); } // End size() -size(); +if (!GRID_V2_ENABLED) { + size(); +} $(document).ready(function() { @@ -186,16 +197,18 @@ $(document).ready(function() { NProgress.start(); $(".scrollContent").mCustomScrollbar(); - + $(".artwork-zoom").magnificPopup({ type: 'image', removalDelay: 3800, mainClass: 'mfp-fade' }); - - $(window).smartresize(size); - //size(); - + + if (!GRID_V2_ENABLED) { + $(window).smartresize(size); + } + //size(); + $(".selectme").select2(); $(".summernote").summernote(); $(".summernote_lite").summernote({ @@ -206,41 +219,43 @@ $(document).ready(function() { ['color', ['color']], ] }); - - var $box_gallery = $('.box_gallery'); - $box_gallery.imagesLoaded( function(){ - $box_gallery.isotope({ - itemSelector : '.img-frame', - layoutMode : 'masonry' - }); - }); - var $container_comments = $(".masonry"); - $container_comments.imagesLoaded( function(){ - $container_comments.isotope({ - itemSelector : '.masonry_item', - layoutMode : 'masonry' - }); - }); - - var $container_news = $('.container_news'); - $container_news.imagesLoaded( function(){ - $container_news.isotope({ - itemSelector : '.news_frame', - layoutMode : 'masonry' - }); - }); - + if (!GRID_V2_ENABLED) { + var $box_gallery = $('.box_gallery'); + $box_gallery.imagesLoaded( function(){ + $box_gallery.isotope({ + itemSelector : '.img-frame', + layoutMode : 'masonry' + }); + }); + + var $container_comments = $(".masonry"); + $container_comments.imagesLoaded( function(){ + $container_comments.isotope({ + itemSelector : '.masonry_item', + layoutMode : 'masonry' + }); + }); + + var $container_news = $('.container_news'); + $container_news.imagesLoaded( function(){ + $container_news.isotope({ + itemSelector : '.news_frame', + layoutMode : 'masonry' + }); + }); + } + if ($("a[rel^='prettyPhoto']").length > 0) { $("a[rel^='prettyPhoto']").prettyPhoto({theme:'dark_rounded'}); } - + $("#sideBarSep").click(function() { if(sbc == 0) { - + $("#sideBarChat").animate({ height: '700px' }, 1000, function(){ @@ -263,13 +278,13 @@ $(document).ready(function() { } }); - + if ($(".followingButton").length > 0) { $(".followingButton").click(function() { $("#showNoticeBox").load("/include/hideNoticeBox.php?show=following"); }); } - + if ($(".streamPost").length > 0) { var remme = false; $(".stream_Remove").click(function() { @@ -280,7 +295,7 @@ $(document).ready(function() { remme = true; } }); - + $(".streamPost").click(function() { $("BODY").scrollTop(0); var wid = $(this).attr("rel"); @@ -292,41 +307,41 @@ $(document).ready(function() { } remme = false; }); - - + + } - + if ($("#js-news").length >0) { $('#js-news').ticker(); } - + if ($("#imageTicker").length >0) { $("#imageTicker").slideDown().newsticker(); } - + if ($(".changeCoverArt").length > 0) { $(".changeCoverArt").click(function() { $("#mywindow").center(); $("#mywindow").show(); }); } - + $(".openwin").fancybox({}); - + $('#ajaxForm').submit(function() { alert('Handler for .submit() called.'); return false; }); - - + + $(".btn_category").fancybox({ 'href' : '/ajax/show-categories', 'width' : 600, 'height' : 200, 'transitionIn' : 'fade' }); - + $(".navbar-fixed-top").autoHidingNavbar({ }); @@ -348,7 +363,7 @@ $(document).ready(function() { if ($("#chat_box").length > 0) { InitChat(); } - + $("#loginMenu span").click(function() { $("#subLoginMenu").toggle(); }); @@ -357,7 +372,7 @@ $(document).ready(function() { $("#browseMenu").click(function(){ //showCategories(); $("#browserMenuList").toggle(); - + }); } @@ -379,7 +394,7 @@ $(document).ready(function() { $("#update_button").html("Attach link"); $("#streamMessage").val("http://"); }); - + $("#publishButton").click(function(){ //event.preventDefault(); var type = $("#streamType").val(); @@ -387,16 +402,16 @@ $(document).ready(function() { //alert("/social/getStreamData.php?type="+type+"&data="+data); $("#streamWork").load("/social/getStreamData.php?type="+type+"&data="+data); $("#streamMessage").val(""); - + }); - + $("#shareBox textarea").elastic(); - + /*if($("#total_msgs").length > 0) { showDownloadCounter(); }*/ - - + + $('textarea.tinymce').tinymce({ // Location of TinyMCE script @@ -429,12 +444,12 @@ function showSubCat(id) { function put_smiley(smiley,outp) { document.forms['newMsg'].comment.value += ' ' + smiley + ' '; document.forms['newMsg'].comment.focus(); - + } function put_smiley_art(smiley,outp) { document.forms['newMsg'].commentText.value += ' ' + smiley + ' '; - document.forms['newMsg'].commentText.focus(); + document.forms['newMsg'].commentText.focus(); } function put_smiley_chat(smiley) { @@ -450,14 +465,14 @@ function put_smiley2(smiley,outp) document.forms['newMsg'].Review.focus(); } -function expw(listID) +function expw(listID) { - if (document.getElementById(listID).style.display=="none") + if (document.getElementById(listID).style.display=="none") { document.getElementById(listID).style.display=""; document.getElementById(listID).style.visibility="visible"; } - else + else { document.getElementById(listID).style.display="none"; document.getElementById(listID).style.visibility="hidden"; @@ -465,7 +480,7 @@ function expw(listID) } -function contw(listID) +function contw(listID) { if (listID.style.display=="show") {listID.style.display="";} else {listID.style.display="none";} @@ -475,7 +490,7 @@ function contw(listID) -function Confirm(link,text) +function Confirm(link,text) { if(confirm(text)) window.location=link @@ -510,7 +525,7 @@ function InitChat() { NProgress.done(); } -function compare(s1, s2) { +function compare(s1, s2) { return s1===s2; } @@ -590,12 +605,12 @@ function ShowPrivateMessage(id) { function ShowPrivateMessageList(box, id) { - + if (box !== 'new') { $("#msgList").html(m_html); $("#msgShow").html(''); } - + if (box == 'new') { //alert ("/privmsg.php?ajax=true&action=msgList&box="+box+"&id=" + id); $("#msgShow").load("/privmsg.php?ajax=true&action=msgList&box="+box+"&id=" + id); @@ -628,7 +643,7 @@ function showDailySkins(datums,x){ document.getElementById('tab-'+selected_tab).style.fontWeight = 'normal'; document.getElementById('tab-'+selected_tab).style.backgroundColor = '#eee'; } - + document.getElementById('tab-'+x).style.fontWeight = 'bold'; document.getElementById('tab-'+x).style.backgroundColor = '#ccc'; selected_tab = x; @@ -675,7 +690,7 @@ function showCategories2(namek) //new Effect.Appear(document.getElementById("displayCategories")); document.getElementById("mainDisplay2").style.visibility = "visible"; updateSkinBrowser("displayCategories2",0,0); - } + } } @@ -690,7 +705,7 @@ function findPosX(obj) { var curleft = 0; if(obj.offsetParent) - while(1) + while(1) { curleft += obj.offsetLeft; if(!obj.offsetParent) @@ -749,37 +764,37 @@ function removeNotice(id,type) { $(this).css({position:'absolute', margin:0, top: (top > 0 ? top : 0)+'px', left: (left > 0 ? left : 0)+'px'}); }); } - }); + }); })(jQuery); (function($,sr){ - + // debouncing function from John Hann // http://unscriptable.com/index.php/2009/03/20/debouncing-javascript-methods/ var debounce = function (func, threshold, execAsap) { var timeout; - + return function debounced () { var obj = this, args = arguments; function delayed () { if (!execAsap) func.apply(obj, args); - timeout = null; + timeout = null; }; - + if (timeout) clearTimeout(timeout); else if (execAsap) func.apply(obj, args); - - timeout = setTimeout(delayed, threshold || 100); + + timeout = setTimeout(delayed, threshold || 100); }; } - // smartresize + // smartresize jQuery.fn[sr] = function(fn){ return fn ? this.bind('resize', debounce(fn)) : this.trigger(sr); }; - + })(jQuery,'smartresize'); @@ -800,4 +815,4 @@ function getCookie(cname) { if (c.indexOf(name) == 0) return c.substring(name.length, c.length); } return ""; -} \ No newline at end of file +} diff --git a/resources/css/nova-grid.css b/resources/css/nova-grid.css new file mode 100644 index 00000000..b3279795 --- /dev/null +++ b/resources/css/nova-grid.css @@ -0,0 +1,149 @@ +/* Nova Grid v2 — CSS-first layout (Columns baseline + native masonry progressive enhancement) */ + +[data-grid-version="v2"] .gallery { + columns: 4 260px; + column-gap: 1.5rem; +} + +[data-grid-version="v2"] .gallery > .gallery-item, +[data-grid-version="v2"] .gallery > .nova-card { + break-inside: avoid; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; + margin: 0 0 1.5rem; + width: 100%; + display: inline-block; +} + +[data-grid-version="v2"] .nova-card-media { + position: relative; + overflow: hidden; + background: #111827; + aspect-ratio: 4 / 3; + contain: layout paint style; +} + +[data-grid-version="v2"] .nova-card-media picture, +[data-grid-version="v2"] .nova-card-media source { + display: block; +} + +[data-grid-version="v2"] .nova-card-media img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +[data-grid-version="v2"] .nova-card-media img[data-blur-preview] { + filter: blur(10px); + transform: scale(1.02); +} + +[data-grid-version="v2"] .nova-card-media img[data-blur-preview].is-loaded { + filter: blur(0); + transform: scale(1); +} + +[data-gallery-skeleton] { + display: none; +} + +[data-gallery-skeleton].is-loading { + display: grid !important; + grid-template-columns: inherit; + gap: 1rem; +} + +.nova-skeleton-card { + border-radius: 14px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.03); +} + +.nova-skeleton-media { + aspect-ratio: 4 / 3; + background: linear-gradient(100deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04)); + background-size: 220% 100%; + animation: novaSkeletonShimmer 1.1s linear infinite; +} + +.nova-skeleton-body { + padding: .75rem; + display: grid; + gap: .45rem; +} + +.nova-skeleton-line, +.nova-skeleton-pill { + height: .6rem; + border-radius: 999px; + background: linear-gradient(100deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.11), rgba(255, 255, 255, 0.04)); + background-size: 220% 100%; + animation: novaSkeletonShimmer 1.1s linear infinite; +} + +.nova-skeleton-pill { + height: .95rem; +} + +@keyframes novaSkeletonShimmer { + 0% { background-position: 180% 0; } + 100% { background-position: -40% 0; } +} + +@media (max-width: 1279px) { + [data-grid-version="v2"] .gallery { + columns: 3 240px; + } +} + +@media (max-width: 1023px) { + [data-grid-version="v2"] .gallery { + columns: 2 220px; + column-gap: 1rem; + } +} + +@media (max-width: 639px) { + [data-grid-version="v2"] .gallery { + columns: 1 100%; + column-gap: 0; + } + + [data-grid-version="v2"] .gallery > .gallery-item, + [data-grid-version="v2"] .gallery > .nova-card { + margin-bottom: 1rem; + } +} + +@supports (grid-template-rows: masonry) { + [data-grid-version="v2"] .gallery { + columns: initial; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + grid-template-rows: masonry; + gap: 1.5rem; + } + + [data-grid-version="v2"] .gallery > .gallery-item, + [data-grid-version="v2"] .gallery > .nova-card { + margin: 0; + display: block; + } + + @media (max-width: 1279px) { + [data-grid-version="v2"] .gallery { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; + } + } + + @media (max-width: 639px) { + [data-grid-version="v2"] .gallery { + grid-template-columns: 1fr; + gap: 1rem; + } + } +} diff --git a/resources/js/nova.js b/resources/js/nova.js index 175f7e94..cb98fd87 100644 --- a/resources/js/nova.js +++ b/resources/js/nova.js @@ -3,6 +3,34 @@ // - mobile menu toggle via [data-mobile-toggle] + #mobileMenu (function () { + function initBlurPreviewImages() { + var selector = 'img[data-blur-preview]'; + + function markLoaded(img) { + if (!img) return; + img.classList.remove('blur-sm', 'scale-[1.02]'); + img.classList.add('is-loaded'); + } + + document.querySelectorAll(selector).forEach(function (img) { + if (img.complete && img.naturalWidth > 0) { + markLoaded(img); + return; + } + img.addEventListener('load', function () { markLoaded(img); }, { once: true }); + img.addEventListener('error', function () { markLoaded(img); }, { once: true }); + }); + + document.addEventListener('load', function (event) { + var target = event.target; + if (target && target.matches && target.matches(selector)) { + markLoaded(target); + } + }, true); + } + + initBlurPreviewImages(); + function closest(el, selector) { while (el && el.nodeType === 1) { if (el.matches(selector)) return el; diff --git a/resources/views/components/artwork-card.blade.php b/resources/views/components/artwork-card.blade.php new file mode 100644 index 00000000..bea9e462 --- /dev/null +++ b/resources/views/components/artwork-card.blade.php @@ -0,0 +1,183 @@ +@props([ + 'art', + 'loading' => 'lazy', + 'fetchpriority' => null, +]) + +@php + if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) { + $first = null; + if (is_array($art)) { + $first = reset($art); + } elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) { + $first = $art->first(); + } + if ($first) { + $art = $first; + } + } + + $title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork')); + + $author = trim((string) ( + $art->uname + ?? $art->author_name + ?? $art->author + ?? ($art->user->name ?? null) + ?? ($art->user->username ?? null) + ?? 'Skinbase' + )); + + $username = trim((string) ( + $art->username + ?? ($art->user->username ?? null) + ?? '' + )); + + $category = trim((string) ($art->category_name ?? $art->category ?? '')); + + $avatarUserId = $art->user->id ?? $art->user_id ?? null; + $avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? null; + $avatarUrl = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 40); + + $license = trim((string) ($art->license ?? 'Standard')); + $resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : ''))); + + $safeInt = function ($value, $fallback = 0) { + if (is_numeric($value)) { + return (int) $value; + } + if (is_array($value)) { + return count($value); + } + if (is_object($value)) { + if (method_exists($value, 'count')) { + return (int) $value->count(); + } + if ($value instanceof Countable) { + return (int) count($value); + } + } + return (int) $fallback; + }; + + $likes = $safeInt($art->likes ?? $art->favourites ?? 0); + $comments = $safeInt($art->comments_count ?? $art->comment_count ?? $art->comments ?? 0); + + $imgSrc = (string) ($art->thumb ?? $art->thumbnail_url ?? '/images/placeholder.jpg'); + $imgSrcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $imgSrc); + $imgAvifSrcset = (string) ($art->thumb_avif_srcset ?? $imgSrcset); + $imgWebpSrcset = (string) ($art->thumb_webp_srcset ?? $imgSrcset); + + $resolveDimension = function ($value, string $field, $fallback) { + if (is_numeric($value)) { + return (int) $value; + } + if (is_array($value)) { + $current = reset($value); + return is_numeric($current) ? (int) $current : (int) $fallback; + } + if (is_object($value)) { + if (method_exists($value, 'first')) { + $first = $value->first(); + if (is_object($first) && isset($first->{$field})) { + return (int) ($first->{$field} ?: $fallback); + } + } + if (isset($value->{$field})) { + return (int) $value->{$field}; + } + } + return (int) $fallback; + }; + + $imgWidth = max(1, $resolveDimension($art->width ?? null, 'width', 800)); + $imgHeight = max(1, $resolveDimension($art->height ?? null, 'height', 600)); + $imgAspectRatio = $imgWidth . ' / ' . $imgHeight; + + $contentUrl = $imgSrc; + $cardUrl = (string) ($art->url ?? ''); + if ($cardUrl === '' || $cardUrl === '#') { + if (isset($art->id) && is_numeric($art->id)) { + $cardUrl = '/art/' . (int) $art->id . '/' . \Illuminate\Support\Str::slug($title); + } else { + $cardUrl = '#'; + } + } + $authorUrl = $username !== '' ? '/@' . strtolower($username) : null; + + $metaParts = []; + if ($resolution !== '') { + $metaParts[] = $resolution; + } + if ($category !== '') { + $metaParts[] = $category; + } + if ($license !== '') { + $metaParts[] = $license; + } +@endphp + + diff --git a/resources/views/components/skeleton/artwork-card.blade.php b/resources/views/components/skeleton/artwork-card.blade.php new file mode 100644 index 00000000..10bbe90d --- /dev/null +++ b/resources/views/components/skeleton/artwork-card.blade.php @@ -0,0 +1,8 @@ + diff --git a/resources/views/dashboard/favorites.blade.php b/resources/views/dashboard/favorites.blade.php index a530c70d..2a698292 100644 --- a/resources/views/dashboard/favorites.blade.php +++ b/resources/views/dashboard/favorites.blade.php @@ -1,5 +1,7 @@ @extends('layouts.nova') +@php($gridV2 = request()->query('grid') === 'v2') + @section('content')

Favourites

@@ -20,44 +22,28 @@ @if($artworks->isEmpty())

You have no favourites yet.

@else -
- - - - - - - - - - - - @foreach($artworks as $art) - - - - - - - - @endforeach - -
ThumbNameAuthorPublishedActions
- - {{ $art->title }} - - - {{ $art->title }} - {{ $art->author }}{{ optional($art->published_at)->format('Y-m-d') }} -
- @csrf - @method('DELETE') - -
-
-
+
+
+ @foreach($artworks as $art) + + @endforeach +
-
{{ $artworks->links() }}
+
{{ $artworks->links() }}
+ + +
@endif
@endsection diff --git a/resources/views/gallery/index.blade.php b/resources/views/gallery/index.blade.php index 9cf20a2e..ce0fcce8 100644 --- a/resources/views/gallery/index.blade.php +++ b/resources/views/gallery/index.blade.php @@ -2,6 +2,7 @@ @php use App\Banner; + $gridV2 = request()->query('grid') === 'v2'; @endphp @section('content') @@ -94,9 +95,13 @@
-
+
@forelse ($artworks as $art) - @include('legacy._artwork_card', ['art' => $art]) + @empty
No Artworks Yet
@@ -112,6 +117,9 @@ {{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : $artworks->links() }} @endif
+
@@ -122,6 +130,7 @@ @endsection @push('styles') +@if(! $gridV2) +@endif @endpush @push('scripts') diff --git a/resources/views/layouts/nova.blade.php b/resources/views/layouts/nova.blade.php index 15e6cbae..01848386 100644 --- a/resources/views/layouts/nova.blade.php +++ b/resources/views/layouts/nova.blade.php @@ -1,5 +1,8 @@ +@php + $gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1'; +@endphp - + {{ $page_title ?? 'Skinbase' }} @@ -7,15 +10,24 @@ + @isset($page_robots) + + @endisset @isset($page_canonical) @endisset + @isset($page_rel_prev) + + @endisset + @isset($page_rel_next) + + @endisset - @vite(['resources/css/app.css','resources/scss/nova.scss','resources/js/nova.js']) + @vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js']) +@endif @endpush @push('scripts') diff --git a/resources/views/web/partials/_artwork_card.blade.php b/resources/views/web/partials/_artwork_card.blade.php index a2a01771..274b7e41 100644 --- a/resources/views/web/partials/_artwork_card.blade.php +++ b/resources/views/web/partials/_artwork_card.blade.php @@ -1,128 +1 @@ -@php - // If a Collection or array was passed accidentally, pick the first item. - if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) { - $first = null; - if (is_array($art)) { - $first = reset($art); - } elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) { - $first = $art->first(); - } - if ($first) { - $art = $first; - } - } - - $title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork')); - $author = trim((string) ( - $art->uname - ?? $art->author_name - ?? $art->author - ?? ($art->user->name ?? null) - ?? ($art->user->username ?? null) - ?? 'Skinbase' - )); - $category = trim((string) ($art->category_name ?? $art->category ?? '')); - $avatarUserId = $art->user->id ?? $art->user_id ?? null; - $avatarHash = $art->user->profile->avatar_hash ?? $art->avatar_hash ?? null; - $avatar_url = \App\Support\AvatarUrl::forUser((int) ($avatarUserId ?? 0), $avatarHash, 40); - $license = trim((string) ($art->license ?? 'Standard')); - $resolution = trim((string) ($art->resolution ?? ((isset($art->width, $art->height) && $art->width && $art->height) ? ($art->width . '×' . $art->height) : ''))); - // Safe integer extractor: handle numeric, arrays, Collections, or relations - $safeInt = function ($v, $fallback = 0) { - if (is_numeric($v)) return (int) $v; - if (is_array($v)) return count($v); - if (is_object($v)) { - if (method_exists($v, 'count')) return (int) $v->count(); - if ($v instanceof Countable) return (int) count($v); - } - return (int) $fallback; - }; - - $likes = $safeInt($art->likes ?? $art->favourites ?? 0); - $comments = $safeInt($art->comments_count ?? $art->comment_count ?? $art->comments ?? 0); - - $img_src = (string) ($art->thumb ?? $art->thumbnail_url ?? '/images/placeholder.jpg'); - $img_srcset = (string) ($art->thumb_srcset ?? $art->thumbnail_srcset ?? $img_src); - $img_avif_srcset = (string) ($art->thumb_avif_srcset ?? $img_srcset); - $img_webp_srcset = (string) ($art->thumb_webp_srcset ?? $img_srcset); - - $resolveDimension = function ($val, $fallback) { - if (is_numeric($val)) return (int) $val; - if (is_array($val)) { - $v = reset($val); - return is_numeric($v) ? (int) $v : (int) $fallback; - } - if (is_object($val)) { - if (method_exists($val, 'first')) { - $f = $val->first(); - if (is_object($f) && isset($f->width)) return (int) ($f->width ?: $fallback); - if (is_object($f) && isset($f->height)) return (int) ($f->height ?: $fallback); - } - if (isset($val->width)) return (int) $val->width; - if (isset($val->height)) return (int) $val->height; - } - return (int) $fallback; - }; - - $img_width = max(1, $resolveDimension($art->width ?? null, 800)); - $img_height = max(1, $resolveDimension($art->height ?? null, 600)); - - $contentUrl = $img_src; - $cardUrl = (string) ($art->url ?? '#'); -@endphp - -
- - - - - - -
+ diff --git a/tests/Feature/BrowseApiTest.php b/tests/Feature/BrowseApiTest.php index 6cbab0e6..7f679d9d 100644 --- a/tests/Feature/BrowseApiTest.php +++ b/tests/Feature/BrowseApiTest.php @@ -7,13 +7,134 @@ use App\Models\Category; use App\Models\ContentType; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Str; use Tests\TestCase; class BrowseApiTest extends TestCase { use RefreshDatabase; + public function test_web_browse_renders_canonical_and_rel_prev_next_for_paginated_pages(): void + { + $user = User::factory()->create(['name' => 'Seo Author']); + $contentType = ContentType::create([ + 'name' => 'Skins', + 'slug' => 'skins', + 'description' => 'Skins content type', + ]); + + $category = Category::create([ + 'content_type_id' => $contentType->id, + 'name' => 'Classic', + 'slug' => 'classic', + 'description' => 'Classic skins', + 'is_active' => true, + 'sort_order' => 1, + ]); + + for ($i = 1; $i <= 25; $i++) { + $artwork = Artwork::factory() + ->for($user) + ->create([ + 'title' => 'Seo Item ' . $i, + 'slug' => 'seo-item-' . $i, + 'published_at' => now()->subMinutes($i), + ]); + $artwork->categories()->attach($category->id); + } + + $response = $this->get('/browse?limit=12&grid=v2'); + $response->assertOk(); + + $html = $response->getContent(); + $this->assertNotFalse($html); + $this->assertStringContainsString('', $html); + $this->assertMatchesRegularExpression('//i', $html); + preg_match('//i', $html, $canonicalMatches); + $this->assertArrayHasKey(1, $canonicalMatches); + $canonicalUrl = html_entity_decode((string) $canonicalMatches[1], ENT_QUOTES); + $this->assertStringNotContainsString('grid=v2', $canonicalUrl); + + $this->assertMatchesRegularExpression('//i', $html); + preg_match('//i', $html, $nextMatches); + $this->assertArrayHasKey(1, $nextMatches); + $nextUrl = html_entity_decode((string) $nextMatches[1], ENT_QUOTES); + $this->assertStringContainsString('cursor=', $nextUrl); + $this->assertStringNotContainsString('grid=v2', $nextUrl); + + $secondPage = $this->get($nextUrl); + $secondPage->assertOk(); + $secondHtml = $secondPage->getContent(); + $this->assertNotFalse($secondHtml); + $this->assertMatchesRegularExpression('//i', $secondHtml); + preg_match('//i', $secondHtml, $prevMatches); + $this->assertArrayHasKey(1, $prevMatches); + $prevUrl = html_entity_decode((string) $prevMatches[1], ENT_QUOTES); + $this->assertStringNotContainsString('grid=v2', $prevUrl); + $this->assertMatchesRegularExpression('//i', $secondHtml, $secondCanonicalMatches); + $this->assertArrayHasKey(1, $secondCanonicalMatches); + $secondCanonicalUrl = html_entity_decode((string) $secondCanonicalMatches[1], ENT_QUOTES); + $this->assertStringNotContainsString('grid=v2', $secondCanonicalUrl); + + $pageOne = $this->get('/browse?limit=12&page=1&grid=v2'); + $pageOne->assertOk(); + $pageOneHtml = $pageOne->getContent(); + $this->assertNotFalse($pageOneHtml); + $this->assertMatchesRegularExpression('//i', $pageOneHtml); + preg_match('//i', $pageOneHtml, $pageOneCanonicalMatches); + $this->assertArrayHasKey(1, $pageOneCanonicalMatches); + $pageOneCanonicalUrl = html_entity_decode((string) $pageOneCanonicalMatches[1], ENT_QUOTES); + $this->assertStringNotContainsString('page=1', $pageOneCanonicalUrl); + } + + public function test_api_browse_supports_limit_and_cursor_pagination(): void + { + $user = User::factory()->create(['name' => 'Cursor Author']); + + $contentType = ContentType::create([ + 'name' => 'Skins', + 'slug' => 'skins', + 'description' => 'Skins content type', + ]); + + $category = Category::create([ + 'content_type_id' => $contentType->id, + 'name' => 'Winamp', + 'slug' => 'winamp', + 'description' => 'Winamp skins', + 'is_active' => true, + 'sort_order' => 1, + ]); + + for ($i = 1; $i <= 6; $i++) { + $artwork = Artwork::factory() + ->for($user) + ->create([ + 'title' => 'Cursor Item ' . $i, + 'slug' => 'cursor-item-' . $i, + 'published_at' => now()->subMinutes($i), + ]); + + $artwork->categories()->attach($category->id); + } + + $first = $this->getJson('/api/v1/browse?limit=2'); + $first->assertOk(); + $first->assertJsonCount(2, 'data'); + + $nextCursor = (string) data_get($first->json(), 'links.next', ''); + $this->assertNotEmpty($nextCursor); + $this->assertStringContainsString('cursor=', $nextCursor); + + $second = $this->getJson($nextCursor); + $second->assertOk(); + $second->assertJsonCount(2, 'data'); + + $firstFirstSlug = data_get($first->json(), 'data.0.slug'); + $secondFirstSlug = data_get($second->json(), 'data.0.slug'); + $this->assertNotSame($firstFirstSlug, $secondFirstSlug); + } + public function test_api_browse_returns_public_artworks(): void { $user = User::factory()->create(['name' => 'Author One']); @@ -82,5 +203,14 @@ class BrowseApiTest extends TestCase $response->assertOk(); $response->assertSee('Forest Light'); $response->assertSee('Author Two'); + + $html = $response->getContent(); + $this->assertNotFalse($html); + $this->assertStringContainsString('itemprop="thumbnailUrl"', $html); + // First card (index 0) is eager-loaded with fetchpriority=high — no blur-preview + $this->assertStringContainsString('loading="eager"', $html); + $this->assertStringContainsString('decoding="sync"', $html); + $this->assertStringContainsString('fetchpriority="high"', $html); + $this->assertMatchesRegularExpression('/]*loading="eager"[^>]*width="\d+"[^>]*height="\d+"/i', $html); } } diff --git a/tests/Feature/DashboardFavoritesTest.php b/tests/Feature/DashboardFavoritesTest.php index a367f448..471c8bee 100644 --- a/tests/Feature/DashboardFavoritesTest.php +++ b/tests/Feature/DashboardFavoritesTest.php @@ -47,11 +47,19 @@ class DashboardFavoritesTest extends TestCase DB::table($favTable)->insert($insert); - $this->actingAs($user) + $response = $this->actingAs($user) ->get(route('dashboard.favorites')) ->assertOk() ->assertSee('Fav Artwork'); + $html = $response->getContent(); + $this->assertNotFalse($html); + $this->assertStringContainsString('itemprop="thumbnailUrl"', $html); + $this->assertStringContainsString('data-blur-preview', $html); + $this->assertStringContainsString('loading="lazy"', $html); + $this->assertStringContainsString('decoding="async"', $html); + $this->assertMatchesRegularExpression('/]*data-blur-preview[^>]*width="\d+"[^>]*height="\d+"/i', $html); + $this->actingAs($user) ->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id])) ->assertRedirect(route('dashboard.favorites'));