gallery fix
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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 = '<div class="nova-skeleton-media"></div><div class="nova-skeleton-body"><div class="nova-skeleton-line"></div><div class="nova-skeleton-line"></div><div class="nova-skeleton-pill"></div></div>';
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,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;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
function size() {
|
||||
@@ -155,29 +162,33 @@ function size() {
|
||||
//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() {
|
||||
@@ -193,7 +204,9 @@ $(document).ready(function() {
|
||||
mainClass: 'mfp-fade'
|
||||
});
|
||||
|
||||
$(window).smartresize(size);
|
||||
if (!GRID_V2_ENABLED) {
|
||||
$(window).smartresize(size);
|
||||
}
|
||||
//size();
|
||||
|
||||
$(".selectme").select2();
|
||||
@@ -207,29 +220,31 @@ $(document).ready(function() {
|
||||
]
|
||||
});
|
||||
|
||||
var $box_gallery = $('.box_gallery');
|
||||
$box_gallery.imagesLoaded( function(){
|
||||
$box_gallery.isotope({
|
||||
itemSelector : '.img-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_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'
|
||||
});
|
||||
});
|
||||
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'});
|
||||
|
||||
@@ -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'});
|
||||
|
||||
@@ -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 = '<div class="nova-skeleton-media"></div><div class="nova-skeleton-body"><div class="nova-skeleton-line"></div><div class="nova-skeleton-line"></div><div class="nova-skeleton-pill"></div></div>';
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,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;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
function size() {
|
||||
@@ -155,29 +162,33 @@ function size() {
|
||||
//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() {
|
||||
@@ -193,7 +204,9 @@ $(document).ready(function() {
|
||||
mainClass: 'mfp-fade'
|
||||
});
|
||||
|
||||
$(window).smartresize(size);
|
||||
if (!GRID_V2_ENABLED) {
|
||||
$(window).smartresize(size);
|
||||
}
|
||||
//size();
|
||||
|
||||
$(".selectme").select2();
|
||||
@@ -207,29 +220,31 @@ $(document).ready(function() {
|
||||
]
|
||||
});
|
||||
|
||||
var $box_gallery = $('.box_gallery');
|
||||
$box_gallery.imagesLoaded( function(){
|
||||
$box_gallery.isotope({
|
||||
itemSelector : '.img-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_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'
|
||||
});
|
||||
});
|
||||
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'});
|
||||
|
||||
149
resources/css/nova-grid.css
Normal file
149
resources/css/nova-grid.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
183
resources/views/components/artwork-card.blade.php
Normal file
183
resources/views/components/artwork-card.blade.php
Normal file
@@ -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
|
||||
|
||||
<article class="nova-card gallery-item artwork" itemscope itemtype="https://schema.org/ImageObject">
|
||||
<meta itemprop="name" content="{{ $title }}">
|
||||
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
|
||||
<meta itemprop="creator" content="{{ $author }}">
|
||||
<meta itemprop="license" content="{{ $license }}">
|
||||
|
||||
<a href="{{ $cardUrl }}" itemprop="url" class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
|
||||
|
||||
@if($category !== '')
|
||||
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
|
||||
@endif
|
||||
|
||||
<div class="nova-card-media relative overflow-hidden bg-neutral-900" style="aspect-ratio: {{ $imgAspectRatio }};">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent"></div>
|
||||
<picture>
|
||||
<source srcset="{{ $imgAvifSrcset }}" type="image/avif">
|
||||
<source srcset="{{ $imgWebpSrcset }}" type="image/webp">
|
||||
<img
|
||||
src="{{ $imgSrc }}"
|
||||
srcset="{{ $imgSrcset }}"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading="{{ $loading }}"
|
||||
decoding="{{ $loading === 'eager' ? 'sync' : 'async' }}"
|
||||
@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]"
|
||||
itemprop="thumbnailUrl"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<div class="absolute right-3 top-3 z-30 flex items-center gap-2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
|
||||
<span class="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">View</span>
|
||||
@if($authorUrl)
|
||||
<span class="inline-flex items-center rounded-md bg-black/60 px-2 py-1 text-[11px] font-medium text-white ring-1 ring-white/10">Profile</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $title }}</div>
|
||||
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
|
||||
<span class="truncate flex items-center gap-2">
|
||||
<img src="{{ $avatarUrl }}" alt="Avatar of {{ e($author) }}" class="w-6 h-6 rounded-full object-cover">
|
||||
<span class="truncate">
|
||||
<span>{{ $author }}</span>
|
||||
@if($username !== '')
|
||||
<span class="text-white/60">{{ '@' . $username }}</span>
|
||||
@endif
|
||||
</span>
|
||||
</span>
|
||||
<span class="shrink-0">❤ {{ $likes }} · 💬 {{ $comments }}</span>
|
||||
</div>
|
||||
@if(!empty($metaParts))
|
||||
<div class="mt-1 text-[11px] text-white/70">{{ implode(' • ', $metaParts) }}</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="sr-only">{{ $title }} by {{ $author }}</span>
|
||||
</a>
|
||||
</article>
|
||||
@@ -0,0 +1,8 @@
|
||||
<div class="nova-skeleton-card" aria-hidden="true">
|
||||
<div class="nova-skeleton-media"></div>
|
||||
<div class="nova-skeleton-body">
|
||||
<div class="nova-skeleton-line w-3/4"></div>
|
||||
<div class="nova-skeleton-line w-1/2"></div>
|
||||
<div class="nova-skeleton-pill w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,7 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
|
||||
@section('content')
|
||||
<div class="container mx-auto py-8">
|
||||
<h1 class="text-2xl font-semibold mb-4">Favourites</h1>
|
||||
@@ -20,44 +22,28 @@
|
||||
@if($artworks->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have no favourites yet.</p>
|
||||
@else
|
||||
<div class="overflow-x-auto bg-panel rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-panel">
|
||||
<th class="p-2 text-left">Thumb</th>
|
||||
<th class="p-2 text-left">Name</th>
|
||||
<th class="p-2 text-left">Author</th>
|
||||
<th class="p-2 text-left">Published</th>
|
||||
<th class="p-2 text-left">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($artworks as $art)
|
||||
<tr class="border-b border-panel">
|
||||
<td class="p-2 w-24">
|
||||
<a href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">
|
||||
<img src="{{ $art->thumb ?? '/gfx/sb_join.jpg' }}" alt="{{ $art->title }}" class="w-20 h-12 object-cover rounded" />
|
||||
</a>
|
||||
</td>
|
||||
<td class="p-2">
|
||||
<a href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}" class="font-medium">{{ $art->title }}</a>
|
||||
</td>
|
||||
<td class="p-2">{{ $art->author }}</td>
|
||||
<td class="p-2">{{ optional($art->published_at)->format('Y-m-d') }}</td>
|
||||
<td class="p-2">
|
||||
<form method="POST" action="{{ route('dashboard.favorites.destroy', ['artwork' => $art->id]) }}" onsubmit="return confirm('Really remove from favourites?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-500 hover:underline">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<section data-nova-gallery data-gallery-type="dashboard-favorites">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
|
||||
@foreach($artworks as $art)
|
||||
<div class="relative gallery-item">
|
||||
<x-artwork-card :art="$art" />
|
||||
<div class="absolute right-2 top-2 z-40">
|
||||
<form method="POST" action="{{ route('dashboard.favorites.destroy', ['artwork' => $art->id]) }}" onsubmit="return confirm('Really remove from favourites?');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="rounded-md border border-white/15 bg-black/60 px-2 py-1 text-xs text-red-300 hover:text-red-200">Remove</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-6">{{ $artworks->links() }}</div>
|
||||
<div class="mt-6" data-gallery-pagination>{{ $artworks->links() }}</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
@php
|
||||
use App\Banner;
|
||||
$gridV2 = request()->query('grid') === 'v2';
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
@@ -94,9 +95,13 @@
|
||||
</div>
|
||||
|
||||
<section class="px-6 pb-10 pt-8 md:px-10" data-nova-gallery data-gallery-type="{{ $gallery_type ?? 'browse' }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 force-5" data-gallery-grid>
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 force-5' }}" data-gallery-grid>
|
||||
@forelse ($artworks as $art)
|
||||
@include('legacy._artwork_card', ['art' => $art])
|
||||
<x-artwork-card
|
||||
:art="$art"
|
||||
:loading="$loop->index < 8 ? 'eager' : 'lazy'"
|
||||
:fetchpriority="$loop->index === 0 ? 'high' : null"
|
||||
/>
|
||||
@empty
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
|
||||
@@ -112,6 +117,9 @@
|
||||
{{ method_exists($artworks, 'withQueryString') ? $artworks->withQueryString()->links() : $artworks->links() }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -122,6 +130,7 @@
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
@if(! $gridV2)
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
@@ -206,6 +215,7 @@
|
||||
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
|
||||
}
|
||||
</style>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@php
|
||||
$gridVersion = request()->query('grid') === 'v2' ? 'v2' : 'v1';
|
||||
@endphp
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ app()->getLocale() }}">
|
||||
<html lang="{{ app()->getLocale() }}" data-grid-version="{{ $gridVersion }}">
|
||||
<head>
|
||||
<title>{{ $page_title ?? 'Skinbase' }}</title>
|
||||
|
||||
@@ -7,15 +10,24 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="{{ $page_meta_description ?? '' }}">
|
||||
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
|
||||
@isset($page_robots)
|
||||
<meta name="robots" content="{{ $page_robots }}" />
|
||||
@endisset
|
||||
@isset($page_canonical)
|
||||
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||
@endisset
|
||||
@isset($page_rel_prev)
|
||||
<link rel="prev" href="{{ $page_rel_prev }}" />
|
||||
@endisset
|
||||
@isset($page_rel_next)
|
||||
<link rel="next" href="{{ $page_rel_next }}" />
|
||||
@endisset
|
||||
|
||||
<!-- Icons (kept for now to preserve current visual output) -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" />
|
||||
<link rel="shortcut icon" href="/favicon.ico">
|
||||
|
||||
@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'])
|
||||
<style>
|
||||
/* Gallery loading overlay */
|
||||
.nova-loader-overlay {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
@@ -14,18 +16,15 @@
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>Newest Artworks</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="gallery-grid">
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'gallery-grid' }}" data-nova-gallery data-gallery-type="profile" data-gallery-grid>
|
||||
@foreach($artworks as $art)
|
||||
<div class="thumb-card effect2">
|
||||
<a href="/art/{{ $art->id }}/{{ Str::slug($art->name ?? '') }}" class="thumb-link">
|
||||
<img src="{{ $art->thumb }}" srcset="{{ $art->thumb_srcset }}" alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="thumb-meta">
|
||||
<div class="thumb-title">{{ $art->name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<x-artwork-card :art="$art" />
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,3 +42,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
||||
@endpush
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
|
||||
@php($gridV2 = request()->query('grid') === 'v2')
|
||||
|
||||
{{-- Latest uploads grid — use same Nova gallery layout as /browse --}}
|
||||
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6" data-gallery-grid>
|
||||
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
|
||||
@forelse($latestUploads as $upload)
|
||||
@include('web.partials._artwork_card', ['art' => $upload])
|
||||
<x-artwork-card :art="$upload" />
|
||||
@empty
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>No uploads yet</strong></div>
|
||||
@@ -15,10 +17,14 @@
|
||||
<div class="flex justify-center mt-10" data-gallery-pagination>
|
||||
{{-- no pagination for home grid; kept for parity with browse layout --}}
|
||||
</div>
|
||||
<div class="hidden" data-gallery-skeleton-template aria-hidden="true">
|
||||
<x-skeleton.artwork-card />
|
||||
</div>
|
||||
<div class="hidden mt-8" data-gallery-skeleton></div>
|
||||
</section>
|
||||
|
||||
@push('styles')
|
||||
@if(! $gridV2)
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
@@ -39,6 +45,7 @@
|
||||
[data-nova-gallery].is-enhanced [data-gallery-pagination] { display: none; }
|
||||
[data-gallery-skeleton].is-loading { display: grid !important; grid-template-columns: inherit; gap: 1rem; }
|
||||
</style>
|
||||
@endif
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
|
||||
@@ -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
|
||||
|
||||
<article class="nova-card artwork" itemscope itemtype="https://schema.org/ImageObject">
|
||||
<meta itemprop="name" content="{{ $title }}">
|
||||
<meta itemprop="contentUrl" content="{{ $contentUrl }}">
|
||||
<meta itemprop="creator" content="{{ $author }}">
|
||||
<meta itemprop="license" content="{{ $license }}">
|
||||
|
||||
<a href="{{ $cardUrl }}" itemprop="url" class="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20 shadow-lg shadow-black/40 transition-all duration-200 ease-out hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70">
|
||||
|
||||
@if(!empty($category))
|
||||
<div class="absolute left-3 top-3 z-30 rounded-md bg-black/55 px-2 py-1 text-xs text-white backdrop-blur-sm">{{ $category }}</div>
|
||||
@endif
|
||||
|
||||
<div class="nova-card-media relative aspect-[16/10] overflow-hidden bg-neutral-900">
|
||||
<picture>
|
||||
<source srcset="{{ $img_avif_srcset }}" type="image/avif">
|
||||
<source srcset="{{ $img_webp_srcset }}" type="image/webp">
|
||||
<img
|
||||
src="{{ $img_src }}"
|
||||
srcset="{{ $img_srcset }}"
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
alt="{{ e($title) }}"
|
||||
width="{{ $img_width }}"
|
||||
height="{{ $img_height }}"
|
||||
class="h-full w-full object-cover transition-transform duration-200 ease-out group-hover:scale-[1.04]"
|
||||
itemprop="thumbnailUrl"
|
||||
/>
|
||||
</picture>
|
||||
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/40 to-transparent p-3 backdrop-blur-[2px] opacity-100 transition-opacity duration-200 md:opacity-0 md:group-hover:opacity-100 md:group-focus-visible:opacity-100">
|
||||
<div class="truncate text-sm font-semibold text-white">{{ $title }}</div>
|
||||
<div class="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
|
||||
<span class="truncate flex items-center gap-2">
|
||||
<img src="{{ $avatar_url }}" alt="Avatar of {{ e($author) }}" class="w-6 h-6 rounded-full object-cover">
|
||||
<span class="truncate">by {{ $author }}</span>
|
||||
</span>
|
||||
<span class="shrink-0">❤ {{ $likes }} · 💬 {{ $comments }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-white/70">
|
||||
@php
|
||||
$meta_parts = [];
|
||||
if (!empty($resolution)) $meta_parts[] = $resolution;
|
||||
if (!empty($category)) $meta_parts[] = $category;
|
||||
if (!empty($license)) $meta_parts[] = $license;
|
||||
@endphp
|
||||
{{ implode(' • ', $meta_parts) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<span class="sr-only">{{ $title }} by {{ $author }}</span>
|
||||
</a>
|
||||
</article>
|
||||
<x-artwork-card :art="$art" />
|
||||
|
||||
@@ -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('<meta name="robots" content="index,follow" />', $html);
|
||||
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/browse\?limit=12"\s*\/>/i', $html);
|
||||
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $html, $canonicalMatches);
|
||||
$this->assertArrayHasKey(1, $canonicalMatches);
|
||||
$canonicalUrl = html_entity_decode((string) $canonicalMatches[1], ENT_QUOTES);
|
||||
$this->assertStringNotContainsString('grid=v2', $canonicalUrl);
|
||||
|
||||
$this->assertMatchesRegularExpression('/<link rel="next" href="([^"]+)"\s*\/>/i', $html);
|
||||
preg_match('/<link rel="next" href="([^"]+)"\s*\/>/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('/<link rel="prev" href="([^"]+)"\s*\/>/i', $secondHtml);
|
||||
preg_match('/<link rel="prev" href="([^"]+)"\s*\/>/i', $secondHtml, $prevMatches);
|
||||
$this->assertArrayHasKey(1, $prevMatches);
|
||||
$prevUrl = html_entity_decode((string) $prevMatches[1], ENT_QUOTES);
|
||||
$this->assertStringNotContainsString('grid=v2', $prevUrl);
|
||||
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/browse\?[^\"]*cursor=/i', $secondHtml);
|
||||
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/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('/<link rel="canonical" href="[^"]*\/browse\?limit=12"\s*\/>/i', $pageOneHtml);
|
||||
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/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('/<img[^>]*loading="eager"[^>]*width="\d+"[^>]*height="\d+"/i', $html);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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('/<img[^>]*data-blur-preview[^>]*width="\d+"[^>]*height="\d+"/i', $html);
|
||||
|
||||
$this->actingAs($user)
|
||||
->delete(route('dashboard.favorites.destroy', ['artwork' => $art->id]))
|
||||
->assertRedirect(route('dashboard.favorites'));
|
||||
|
||||
Reference in New Issue
Block a user