From 980a15f66ea6c4d7ec58d8f1f6c63ccf2cdb0e75 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Tue, 17 Mar 2026 14:49:20 +0100 Subject: [PATCH] refactor: unify artwork card rendering --- .../Community/LatestController.php | 2 + .../Dashboard/FavoriteController.php | 2 + .../User/TodayInHistoryController.php | 63 ++- .../User/TopFavouritesController.php | 39 +- .../Web/BrowseGalleryController.php | 47 +- .../Controllers/Web/DiscoverController.php | 2 + .../Controllers/Web/ExploreController.php | 2 + .../Web/FeaturedArtworksController.php | 13 + app/Http/Controllers/Web/TagController.php | 49 ++- app/Http/Resources/ArtworkListResource.php | 4 + app/Services/HomepageService.php | 5 + .../components/Feed/EmbeddedArtworkCard.jsx | 71 --- resources/js/components/Feed/PostCard.jsx | 4 +- .../js/components/artwork/ArtworkAuthor.jsx | 153 ++++--- .../js/components/artwork/ArtworkCard.jsx | 409 ++++++++++++++++++ .../js/components/artwork/ArtworkCardMini.jsx | 33 -- .../js/components/artwork/ArtworkGallery.jsx | 58 +++ .../components/artwork/ArtworkGalleryGrid.jsx | 32 ++ .../js/components/artwork/ArtworkRelated.jsx | 38 +- .../components/artwork/CreatorSpotlight.jsx | 221 ++++++---- .../js/components/gallery/ArtworkCard.jsx | 204 --------- .../views/_legacy/home/uploads.blade.php | 10 +- .../views/_legacy/latest-artworks.blade.php | 13 +- .../views/_legacy/today-in-history.blade.php | 116 +++-- .../views/_legacy/top-favourites.blade.php | 93 +++- .../views/components/artwork-card.blade.php | 53 ++- resources/views/dashboard/gallery.blade.php | 13 +- resources/views/web/downloads/today.blade.php | 42 +- resources/views/web/gallery.blade.php | 9 +- resources/views/web/home/uploads.blade.php | 1 - 30 files changed, 1145 insertions(+), 656 deletions(-) delete mode 100644 resources/js/components/Feed/EmbeddedArtworkCard.jsx create mode 100644 resources/js/components/artwork/ArtworkCard.jsx delete mode 100644 resources/js/components/artwork/ArtworkCardMini.jsx create mode 100644 resources/js/components/artwork/ArtworkGallery.jsx create mode 100644 resources/js/components/artwork/ArtworkGalleryGrid.jsx delete mode 100644 resources/js/components/gallery/ArtworkCard.jsx diff --git a/app/Http/Controllers/Community/LatestController.php b/app/Http/Controllers/Community/LatestController.php index dfcc81c3..52fb75d8 100644 --- a/app/Http/Controllers/Community/LatestController.php +++ b/app/Http/Controllers/Community/LatestController.php @@ -31,6 +31,8 @@ class LatestController extends Controller return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $categoryName, 'gid_num' => $gid, 'thumb_url' => $present['url'], diff --git a/app/Http/Controllers/Dashboard/FavoriteController.php b/app/Http/Controllers/Dashboard/FavoriteController.php index 152d4d05..8da73632 100644 --- a/app/Http/Controllers/Dashboard/FavoriteController.php +++ b/app/Http/Controllers/Dashboard/FavoriteController.php @@ -66,6 +66,8 @@ class FavoriteController extends Controller $a->user?->profile?->avatar_hash ?? null, 64 ), + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->slug ?? '', 'width' => $a->width, diff --git a/app/Http/Controllers/User/TodayInHistoryController.php b/app/Http/Controllers/User/TodayInHistoryController.php index 74b8408a..cdb96485 100644 --- a/app/Http/Controllers/User/TodayInHistoryController.php +++ b/app/Http/Controllers/User/TodayInHistoryController.php @@ -4,9 +4,11 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; use App\Models\Artwork; +use App\Support\AvatarUrl; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Str; class TodayInHistoryController extends Controller { @@ -61,21 +63,68 @@ class TodayInHistoryController extends Controller // ── Enrich with CDN thumbnails (batch load to avoid N+1) ───────────────── if ($artworks && method_exists($artworks, 'getCollection') && $artworks->count() > 0) { - $ids = $artworks->getCollection()->pluck('id')->all(); - $modelsById = Artwork::whereIn('id', $ids)->get()->keyBy('id'); + $ids = $artworks->getCollection()->pluck('id')->filter()->map(fn ($id) => (int) $id)->all(); + $modelsById = Artwork::query() + ->with([ + 'user:id,name,username', + 'categories' => function ($query) { + $query->select('categories.id', 'categories.name', 'categories.slug', 'categories.sort_order'); + }, + ]) + ->whereIn('id', $ids) + ->get() + ->keyBy('id'); $artworks->getCollection()->transform(function ($row) use ($modelsById) { /** @var ?Artwork $art */ $art = $modelsById->get($row->id); + $row->slug = $row->slug ?? Str::slug($row->name ?? ''); + if ($art) { - $row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; - $row->art_url = '/art/' . $art->id . '/' . $art->slug; - $row->name = $art->title ?: ($row->name ?? 'Untitled'); + $primaryCategory = $art->categories?->sortBy('sort_order')->first(); + $author = $art->user; + + try { + $present = \App\Services\ThumbnailPresenter::present($art, 'md'); + $row->thumb_url = $present['url']; + $row->thumb_srcset = $present['srcset'] ?? $present['url']; + } catch (\Throwable $e) { + $row->thumb_url = $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp'; + $row->thumb_srcset = $row->thumb_url; + } + + $row->url = url('/art/' . $art->id . '/' . ($art->slug ?: Str::slug($art->title ?: ($row->name ?? 'artwork')))); + $row->art_url = $row->url; + $row->name = $art->title ?: ($row->name ?? 'Untitled'); + $row->slug = $art->slug ?: $row->slug; + $row->width = $art->width; + $row->height = $art->height; + $row->content_type_name = $primaryCategory?->contentType?->name ?? ''; + $row->content_type_slug = $primaryCategory?->contentType?->slug ?? ''; + $row->category_name = $primaryCategory->name ?? ''; + $row->category_slug = $primaryCategory->slug ?? ''; + $row->uname = $author->name ?? 'Skinbase'; + $row->username = $author->username ?? $author->name ?? ''; + $row->avatar_url = $author + ? AvatarUrl::forUser((int) $author->getKey(), null, 64) + : AvatarUrl::default(); } else { $row->thumb_url = 'https://files.skinbase.org/default/missing_md.webp'; - $row->art_url = '/art/' . $row->id; - $row->name = $row->name ?? 'Untitled'; + $row->thumb_srcset = $row->thumb_url; + $row->url = url('/art/' . $row->id . '/' . ($row->slug ?: Str::slug($row->name ?? 'artwork'))); + $row->art_url = $row->url; + $row->name = $row->name ?? 'Untitled'; + $row->content_type_name = $row->content_type_name ?? ''; + $row->content_type_slug = $row->content_type_slug ?? ''; + $row->category_name = $row->category_name ?? ''; + $row->category_slug = $row->category_slug ?? ''; + $row->uname = $row->uname ?? 'Skinbase'; + $row->username = $row->username ?? ''; + $row->avatar_url = $row->avatar_url ?? AvatarUrl::default(); + $row->width = $row->width ?? null; + $row->height = $row->height ?? null; } + return $row; }); } diff --git a/app/Http/Controllers/User/TopFavouritesController.php b/app/Http/Controllers/User/TopFavouritesController.php index adcfbb44..fb1d25b1 100644 --- a/app/Http/Controllers/User/TopFavouritesController.php +++ b/app/Http/Controllers/User/TopFavouritesController.php @@ -3,10 +3,11 @@ namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; +use App\Models\Artwork; +use App\Support\AvatarUrl; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; -use App\Services\LegacyService; class TopFavouritesController extends Controller { @@ -28,14 +29,30 @@ class TopFavouritesController extends Controller } if ($paginator && method_exists($paginator, 'getCollection')) { - $paginator->getCollection()->transform(function ($row) { + $artworkLookup = Artwork::query() + ->with([ + 'user:id,name,username', + 'categories' => function ($query) { + $query->select('categories.id', 'categories.name', 'categories.slug', 'categories.sort_order'); + }, + ]) + ->whereIn('id', $paginator->getCollection()->pluck('id')->filter()->map(fn ($id) => (int) $id)->all()) + ->get() + ->keyBy('id'); + + $paginator->getCollection()->transform(function ($row) use ($artworkLookup) { $row->slug = $row->slug ?? Str::slug($row->name ?? ''); $ext = pathinfo($row->picture ?? '', PATHINFO_EXTENSION) ?: 'jpg'; $encoded = \App\Helpers\Thumb::encodeId((int) $row->id); $row->encoded = $encoded; $row->ext = $ext; + + /** @var \App\Models\Artwork|null $art */ + $art = $artworkLookup->get((int) $row->id); + $primaryCategory = $art?->categories?->sortBy('sort_order')->first(); + $author = $art?->user; + try { - $art = \App\Models\Artwork::find($row->id); $present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md'); $row->thumb = $row->thumb ?? $present['url']; $row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']); @@ -44,7 +61,23 @@ class TopFavouritesController extends Controller $row->thumb = $row->thumb ?? $present['url']; $row->thumb_srcset = $row->thumb_srcset ?? ($present['srcset'] ?? $present['url']); } + + $row->thumb_url = $row->thumb ?? null; $row->gid_num = ((int)($row->category ?? 0) % 5) * 5; + $row->url = url('/art/' . (int) $row->id . '/' . ($row->slug ?: Str::slug($row->name ?? 'artwork'))); + $row->width = $art?->width; + $row->height = $art?->height; + $row->content_type_name = $primaryCategory?->contentType?->name ?? ''; + $row->content_type_slug = $primaryCategory?->contentType?->slug ?? ''; + $row->category_name = $primaryCategory->name ?? ''; + $row->category_slug = $primaryCategory->slug ?? ''; + $row->uname = $author->name ?? 'Skinbase'; + $row->username = $author->username ?? $author->name ?? ''; + $row->avatar_url = $author + ? AvatarUrl::forUser((int) $author->getKey(), null, 64) + : AvatarUrl::default(); + $row->favourites = (int) ($row->num ?? 0); + return $row; }); } diff --git a/app/Http/Controllers/Web/BrowseGalleryController.php b/app/Http/Controllers/Web/BrowseGalleryController.php index 73652873..9f826e57 100644 --- a/app/Http/Controllers/Web/BrowseGalleryController.php +++ b/app/Http/Controllers/Web/BrowseGalleryController.php @@ -11,6 +11,7 @@ use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; use Illuminate\Pagination\AbstractPaginator; use Illuminate\Pagination\AbstractCursorPaginator; @@ -180,19 +181,25 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller abort(404); } - $catSlug = $category->slug; + $categorySlugs = $this->categoryFilterSlugs($category); + $categoryFilter = collect($categorySlugs) + ->map(fn (string $slug) => 'category = "' . addslashes($slug) . '"') + ->implode(' OR '); + $artworks = Cache::remember( - "gallery.cat.{$catSlug}.{$sort}.{$page}", + 'gallery.cat.' . md5($contentSlug . '|' . implode('|', $categorySlugs)) . ".{$sort}.{$page}", $ttl, fn () => Artwork::search('')->options([ - 'filter' => 'is_public = true AND is_approved = true AND category = "' . $catSlug . '"', + 'filter' => 'is_public = true AND is_approved = true AND (' . $categoryFilter . ')', 'sort' => self::SORT_MAP[$sort] ?? ['created_at:desc'], ])->paginate($perPage) ); $artworks->getCollection()->transform(fn ($a) => $this->presentArtwork($a)); $seo = $this->buildPaginationSeo($request, url('/' . $contentSlug . '/' . strtolower($category->full_slug_path)), $artworks); - $subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get(); + $navigationCategory = $category->parent ?: $category; + + $subcategories = $navigationCategory->children()->orderBy('sort_order')->orderBy('name')->get(); if ($subcategories->isEmpty()) { $subcategories = $rootCategories; } @@ -209,6 +216,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller 'gallery_type' => 'category', 'mainCategories' => $mainCategories, 'subcategories' => $subcategories, + 'subcategory_parent' => $navigationCategory, 'contentType' => $contentType, 'category' => $category, 'artworks' => $artworks, @@ -298,6 +306,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->slug ?? '', 'thumb_url' => $present['url'], @@ -311,6 +321,35 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller ]; } + /** + * Build the category slug filter set for a gallery page. + * Includes the current category and all descendant subcategories. + * + * @return array + */ + private function categoryFilterSlugs(Category $category): array + { + $category->loadMissing('descendants'); + + $slugs = []; + $stack = [$category]; + + while ($stack !== []) { + /** @var Category $current */ + $current = array_pop($stack); + if (! empty($current->slug)) { + $slugs[] = Str::lower($current->slug); + } + + foreach ($current->children as $child) { + $child->loadMissing('descendants'); + $stack[] = $child; + } + } + + return array_values(array_unique($slugs)); + } + private function resolvePerPage(Request $request): int { $limit = (int) $request->query('limit', 0); diff --git a/app/Http/Controllers/Web/DiscoverController.php b/app/Http/Controllers/Web/DiscoverController.php index ecc2ce21..c83268eb 100644 --- a/app/Http/Controllers/Web/DiscoverController.php +++ b/app/Http/Controllers/Web/DiscoverController.php @@ -429,6 +429,8 @@ final class DiscoverController extends Controller return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->slug ?? '', 'gid_num' => $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0, diff --git a/app/Http/Controllers/Web/ExploreController.php b/app/Http/Controllers/Web/ExploreController.php index 60f9767d..3456e0fa 100644 --- a/app/Http/Controllers/Web/ExploreController.php +++ b/app/Http/Controllers/Web/ExploreController.php @@ -285,6 +285,8 @@ final class ExploreController extends Controller return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, + 'content_type_name' => $primary?->contentType?->name ?? '', + 'content_type_slug' => $primary?->contentType?->slug ?? '', 'category_name' => $primary->name ?? '', 'category_slug' => $primary->slug ?? '', 'thumb_url' => $present['url'], diff --git a/app/Http/Controllers/Web/FeaturedArtworksController.php b/app/Http/Controllers/Web/FeaturedArtworksController.php index 23dbf7cf..1bff0a26 100644 --- a/app/Http/Controllers/Web/FeaturedArtworksController.php +++ b/app/Http/Controllers/Web/FeaturedArtworksController.php @@ -6,6 +6,8 @@ use App\Http\Controllers\Controller; use App\Models\Artwork; use App\Services\ArtworkService; use Illuminate\Http\Request; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Str; class FeaturedArtworksController extends Controller { @@ -24,22 +26,33 @@ class FeaturedArtworksController extends Controller $typeFilter = $type === 4 ? null : $type; + /** @var LengthAwarePaginator $artworks */ $artworks = $this->artworks->getFeaturedArtworks($typeFilter, $perPage); $artworks->getCollection()->transform(function (Artwork $artwork) { $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $categoryName = $primaryCategory->name ?? ''; + $categorySlug = $primaryCategory->slug ?? ''; $gid = $primaryCategory ? ((int) $primaryCategory->id % 5) * 5 : 0; $present = \App\Services\ThumbnailPresenter::present($artwork, 'md'); + $username = $artwork->user->username ?? $artwork->user->name ?? 'Skinbase'; return (object) [ 'id' => $artwork->id, 'name' => $artwork->title, + 'slug' => $artwork->slug, + 'url' => url('/art/' . $artwork->id . '/' . Str::slug($artwork->title ?? 'artwork')), + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $categoryName, + 'category_slug' => $categorySlug, 'gid_num' => $gid, 'thumb_url' => $present['url'], 'thumb_srcset' => $present['srcset'] ?? $present['url'], + 'width' => $artwork->width, + 'height' => $artwork->height, 'uname' => $artwork->user->name ?? 'Skinbase', + 'username' => $username, ]; }); diff --git a/app/Http/Controllers/Web/TagController.php b/app/Http/Controllers/Web/TagController.php index 0f3a4a30..e3c84059 100644 --- a/app/Http/Controllers/Web/TagController.php +++ b/app/Http/Controllers/Web/TagController.php @@ -9,6 +9,7 @@ use App\Models\ContentType; use App\Models\Tag; use App\Services\ArtworkSearchService; use App\Services\EarlyGrowth\GridFiller; +use App\Services\Tags\TagDiscoveryService; use App\Services\ThumbnailPresenter; use Illuminate\Http\Request; use Illuminate\View\View; @@ -18,20 +19,27 @@ final class TagController extends Controller public function __construct( private readonly ArtworkSearchService $search, private readonly GridFiller $gridFiller, + private readonly TagDiscoveryService $tagDiscovery, ) {} public function index(Request $request): View { - $tags = \App\Models\Tag::withCount('artworks') - ->orderByDesc('artworks_count') - ->paginate(80) - ->withQueryString(); + $query = trim((string) $request->query('q', '')); + $featuredTags = $this->tagDiscovery->featuredTags(); + $risingTags = $this->tagDiscovery->risingTags($featuredTags); + $tags = $this->tagDiscovery->paginatedTags($query); + $tagStats = $this->tagDiscovery->stats($tags->total()); return view('web.tags.index', [ - 'tags' => $tags, - 'page_title' => 'Browse Tags — Skinbase', - 'page_canonical' => route('tags.index'), - 'page_robots' => 'index,follow', + 'tags' => $tags, + 'query' => $query, + 'featuredTags' => $featuredTags, + 'risingTags' => $risingTags, + 'tagStats' => $tagStats, + 'page_title' => 'Browse Tags — Skinbase', + 'page_meta_description' => 'Explore the most-used artwork tags on Skinbase and jump straight into the styles, themes, and aesthetics you want to browse.', + 'page_canonical' => route('tags.index'), + 'page_robots' => 'index,follow', ]); } @@ -82,6 +90,8 @@ final class TagController extends Controller return (object) [ 'id' => $a->id, 'name' => $a->title ?? ($a->name ?? null), + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'category_name' => $primaryCategory->name ?? '', 'category_slug' => $primaryCategory->slug ?? '', 'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null), @@ -111,6 +121,15 @@ final class TagController extends Controller ]; $gallerySort = $sortMapToGallery[$sort] ?? 'trending'; + $sortLabels = [ + 'popular' => 'Most viewed', + 'likes' => 'Most liked', + 'latest' => 'Latest uploads', + 'downloads' => 'Most downloaded', + ]; + + $relatedTags = $this->tagDiscovery->relatedTags($tag); + // Build simple pagination SEO links $prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null; $next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null; @@ -122,6 +141,7 @@ final class TagController extends Controller 'contentType' => null, 'category' => null, 'artworks' => $artworks, + 'gallery_nav_section' => 'tags', 'current_sort' => $gallerySort, 'sort_options' => [ ['value' => 'trending', 'label' => '🔥 Trending'], @@ -129,8 +149,17 @@ final class TagController extends Controller ['value' => 'top-rated', 'label' => '⭐ Top Rated'], ['value' => 'latest', 'label' => '🕐 Latest'], ], - 'hero_title' => $tag->name, - 'hero_description' => 'Artworks tagged "' . $tag->name . '"', + 'hero_title' => '#' . $tag->name, + 'hero_description' => 'Browse artworks tagged "' . $tag->name . '" and jump between the strongest matching uploads on Skinbase.', + 'tag_context' => [ + 'name' => $tag->name, + 'slug' => $tag->slug, + 'artworks_total' => $artworks->total(), + 'usage_count' => (int) $tag->usage_count, + 'current_sort_label' => $sortLabels[$sort] ?? 'Most viewed', + 'rss_url' => route('rss.tag', ['slug' => $tag->slug]), + 'related_tags' => $relatedTags, + ], 'breadcrumbs' => collect([ (object) ['name' => 'Home', 'url' => '/'], (object) ['name' => 'Tags', 'url' => route('tags.index')], diff --git a/app/Http/Resources/ArtworkListResource.php b/app/Http/Resources/ArtworkListResource.php index a2d1a10d..a88aac29 100644 --- a/app/Http/Resources/ArtworkListResource.php +++ b/app/Http/Resources/ArtworkListResource.php @@ -42,9 +42,11 @@ class ArtworkListResource extends JsonResource } $contentTypeSlug = null; + $contentTypeName = null; $categoryPath = null; if ($primaryCategory) { $contentTypeSlug = optional($primaryCategory->contentType)->slug ?? null; + $contentTypeName = optional($primaryCategory->contentType)->name ?? null; $categoryPath = $primaryCategory->full_slug_path ?? null; } $slugVal = $get('slug'); @@ -79,6 +81,8 @@ class ArtworkListResource extends JsonResource 'slug' => $primaryCategory->slug ?? null, 'name' => $decode($primaryCategory->name ?? null), 'content_type' => $contentTypeSlug, + 'content_type_slug' => $contentTypeSlug, + 'content_type_name' => $decode($contentTypeName), 'url' => $webUrl, ] : null, 'urls' => [ diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index a8615d72..c81a55f0 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -589,6 +589,7 @@ final class HomepageService $thumbMd = $artwork->thumbUrl('md'); $thumbLg = $artwork->thumbUrl('lg'); $thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg); + $primaryCategory = $artwork->categories->sortBy('sort_order')->first(); $authorId = $artwork->user_id; $authorName = $artwork->user?->name ?? 'Artist'; @@ -607,6 +608,10 @@ final class HomepageService 'thumb' => $thumb, 'thumb_md' => $thumbMd, 'thumb_lg' => $thumbLg, + 'category_name' => $primaryCategory->name ?? '', + 'category_slug' => $primaryCategory->slug ?? '', + 'content_type_name' => $primaryCategory?->contentType?->name ?? '', + 'content_type_slug' => $primaryCategory?->contentType?->slug ?? '', 'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''), 'width' => $artwork->width, 'height' => $artwork->height, diff --git a/resources/js/components/Feed/EmbeddedArtworkCard.jsx b/resources/js/components/Feed/EmbeddedArtworkCard.jsx deleted file mode 100644 index e430fc99..00000000 --- a/resources/js/components/Feed/EmbeddedArtworkCard.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' - -/** - * Compact artwork card for embedding inside a PostCard. - * Shows thumbnail, title and original author with attribution. - */ -export default function EmbeddedArtworkCard({ artwork }) { - if (!artwork) return null - - const artUrl = `/art/${artwork.id}/${slugify(artwork.title)}` - const authorUrl = `/@${artwork.author.username}` - - const handleCardClick = (e) => { - // Don't navigate when clicking the author link - if (e.defaultPrevented) return - window.location.href = artUrl - } - - const handleKeyDown = (e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - window.location.href = artUrl - } - } - - return ( - // Outer element is a div to avoid inside — navigation handled via onClick -
- {/* Thumbnail */} -
- {artwork.thumb_url ? ( - {artwork.title} - ) : ( -
- -
- )} -
- - {/* Meta */} -
-

{artwork.title}

-
e.stopPropagation()} - className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate" - > - - by {artwork.author.name || `@${artwork.author.username}`} - - Artwork -
-
- ) -} - -function slugify(str) { - return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') -} diff --git a/resources/js/components/Feed/PostCard.jsx b/resources/js/components/Feed/PostCard.jsx index 547555a9..b7377f02 100644 --- a/resources/js/components/Feed/PostCard.jsx +++ b/resources/js/components/Feed/PostCard.jsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import PostActions from './PostActions' import PostComments from './PostComments' -import EmbeddedArtworkCard from './EmbeddedArtworkCard' +import ArtworkCard from '../artwork/ArtworkCard' import VisibilityPill from './VisibilityPill' import LinkPreviewCard from './LinkPreviewCard' @@ -329,7 +329,7 @@ export default function PostCard({ post, isLoggedIn = false, viewerUsername = nu {/* Artwork share embed */} {postData.type === 'artwork_share' && postData.artwork && ( - + )} diff --git a/resources/js/components/artwork/ArtworkAuthor.jsx b/resources/js/components/artwork/ArtworkAuthor.jsx index 4d4288cd..5cec2c79 100644 --- a/resources/js/components/artwork/ArtworkAuthor.jsx +++ b/resources/js/components/artwork/ArtworkAuthor.jsx @@ -1,10 +1,13 @@ import React, { useState } from 'react' +import NovaConfirmDialog from '../ui/NovaConfirmDialog' const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp' export default function ArtworkAuthor({ artwork, presentSq }) { const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author)) const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0)) + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingFollowState, setPendingFollowState] = useState(null) const user = artwork?.user || {} const authorName = user.name || user.username || 'Artist' const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#') @@ -13,13 +16,7 @@ export default function ArtworkAuthor({ artwork, presentSq }) { ? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') : null - const onToggleFollow = async () => { - if (following) { - const confirmed = window.confirm(`Unfollow @${user.username || user.name || 'this creator'}?`) - if (!confirmed) return - } - - const nextState = !following + const persistFollowState = async (nextState) => { setFollowing(nextState) try { const response = await fetch(`/api/users/${user.id}/follow`, { @@ -43,63 +40,99 @@ export default function ArtworkAuthor({ artwork, presentSq }) { } } + const onToggleFollow = async () => { + const nextState = !following + if (!nextState) { + setPendingFollowState(nextState) + setConfirmOpen(true) + return + } + + await persistFollowState(nextState) + } + + const onConfirmUnfollow = async () => { + if (pendingFollowState === null) return + setConfirmOpen(false) + await persistFollowState(pendingFollowState) + setPendingFollowState(null) + } + + const onCloseConfirm = () => { + setConfirmOpen(false) + setPendingFollowState(null) + } + return ( -
-

Author

+ <> +
+

Author

-
- {authorName} { - event.currentTarget.src = AVATAR_FALLBACK - }} - /> +
+ {authorName} { + event.currentTarget.src = AVATAR_FALLBACK + }} + /> -
- - {authorName} - - {user.username &&

@{user.username}

} -

{followersCount.toLocaleString()} followers

+
+ + {authorName} + + {user.username &&

@{user.username}

} +

{followersCount.toLocaleString()} followers

+
-
-
- - - - - Profile - - -
-
+
+ + + + + Profile + + +
+
+ + + ) } diff --git a/resources/js/components/artwork/ArtworkCard.jsx b/resources/js/components/artwork/ArtworkCard.jsx new file mode 100644 index 00000000..f4db2639 --- /dev/null +++ b/resources/js/components/artwork/ArtworkCard.jsx @@ -0,0 +1,409 @@ +import React, { useEffect, useMemo, useState } from 'react' + +const IMAGE_FALLBACK = 'https://files.skinbase.org/default/missing_md.webp' +const AVATAR_FALLBACK = 'https://files.skinbase.org/default/avatar_default.webp' +const numberFormatter = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, +}) + +function cx(...parts) { + return parts.filter(Boolean).join(' ') +} + +function formatCount(value) { + const numeric = Number(value ?? 0) + if (!Number.isFinite(numeric)) return '0' + return numberFormatter.format(numeric) +} + +function slugify(value) { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +function decodeHtml(value) { + const text = String(value ?? '') + if (!text.includes('&')) return text + + let decoded = text + + for (let index = 0; index < 3; index += 1) { + decoded = decoded + .replace(/&/gi, '&') + .replace(/&(apos|#39);/gi, "'") + .replace(/&(acute|#180|#x00B4);/gi, "'") + .replace(/&(quot|#34);/gi, '"') + .replace(/&(nbsp|#160);/gi, ' ') + + if (typeof document === 'undefined') { + break + } + + const textarea = document.createElement('textarea') + textarea.innerHTML = decoded + const nextValue = textarea.value + if (nextValue === decoded) break + decoded = nextValue + } + + return decoded +} + +function normalizeContentTypeLabel(value) { + const raw = decodeHtml(value).trim() + if (!raw) return '' + + const normalized = raw.toLowerCase() + const knownLabels = { + artworks: 'Artwork', + artwork: 'Artwork', + wallpapers: 'Wallpaper', + wallpaper: 'Wallpaper', + skins: 'Skin', + skin: 'Skin', + photography: 'Photography', + photo: 'Photography', + photos: 'Photography', + other: 'Other', + } + + if (knownLabels[normalized]) { + return knownLabels[normalized] + } + + return raw + .replace(/[-_]+/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) +} + +function getCsrfToken() { + if (typeof document === 'undefined') return '' + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +function HeartIcon(props) { + return ( + + ) +} + +function DownloadIcon(props) { + return ( + + ) +} + +function ViewIcon(props) { + return ( + + ) +} + +function ActionLink({ href, label, children, onClick }) { + return ( + + {children} + + ) +} + +function ActionButton({ label, children, onClick }) { + return ( + + ) +} + +export default function ArtworkCard({ + artwork, + variant = 'default', + compact = false, + showStats = true, + showAuthor = true, + className = '', + articleClassName = '', + frameClassName = '', + mediaClassName = '', + mediaStyle, + articleStyle, + imageClassName = '', + imageSizes, + imageSrcSet, + imageWidth, + imageHeight, + loading = 'lazy', + decoding = 'async', + fetchPriority, + onLike, + showActions = true, +}) { + const item = artwork || {} + const rawAuthor = item.author + const title = decodeHtml(item.title || item.name || 'Untitled artwork') + const author = decodeHtml( + (typeof rawAuthor === 'string' ? rawAuthor : rawAuthor?.name) + || item.author_name + || item.uname + || 'Skinbase Artist' + ) + const username = rawAuthor?.username || item.author_username || item.username || null + const image = item.image || item.thumb || item.thumb_url || item.thumbnail_url || IMAGE_FALLBACK + const avatar = rawAuthor?.avatar_url || rawAuthor?.avatar || item.avatar || item.author_avatar || item.avatar_url || AVATAR_FALLBACK + const likes = item.likes ?? item.favourites ?? 0 + const views = item.views ?? item.views_count ?? item.view_count ?? 0 + const downloads = item.downloads ?? item.downloads_count ?? item.download_count ?? 0 + const contentType = normalizeContentTypeLabel( + item.content_type + || item.content_type_name + || item.contentType + || item.contentTypeName + || item.content_type_slug + || '' + ) + const category = decodeHtml(item.category || item.category_name || '') + const width = Number(item.width ?? 0) + const height = Number(item.height ?? 0) + const resolution = decodeHtml(item.resolution || ((width > 0 && height > 0) ? `${width}x${height}` : '')) + const href = item.url || item.href || (item.id ? `/art/${item.id}/${slugify(title)}` : '#') + const downloadHref = item.download_url || item.downloadHref || (item.id ? `/download/artwork/${item.id}` : href) + const cardLabel = `${title} by ${author}` + const aspectClass = compact ? 'aspect-square' : 'aspect-[4/5]' + const titleClass = compact ? 'text-[0.96rem]' : 'text-[1rem] sm:text-[1.08rem]' + const metadataLine = [contentType, category, resolution].filter(Boolean).join(' • ') + const authorHref = username ? `/@${username}` : null + const initialLiked = Boolean(item.viewer?.is_liked) + const [liked, setLiked] = useState(initialLiked) + const [likeCount, setLikeCount] = useState(Number(likes ?? 0) || 0) + const [likeBusy, setLikeBusy] = useState(false) + const [downloadBusy, setDownloadBusy] = useState(false) + + useEffect(() => { + setLiked(Boolean(item.viewer?.is_liked)) + setLikeCount(Number(item.likes ?? item.favourites ?? 0) || 0) + }, [item.id, item.likes, item.favourites, item.viewer?.is_liked]) + + const articleData = useMemo(() => ({ + 'data-art-id': item.id ?? undefined, + 'data-art-url': href !== '#' ? href : undefined, + 'data-art-title': title, + 'data-art-img': image, + }), [href, image, item.id, title]) + + const handleLike = async () => { + if (!item.id || likeBusy) { + onLike?.(item) + return + } + + const nextState = !liked + setLikeBusy(true) + setLiked(nextState) + setLikeCount((current) => Math.max(0, current + (nextState ? 1 : -1))) + + try { + const response = await fetch(`/api/artworks/${item.id}/like`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': getCsrfToken(), + }, + credentials: 'same-origin', + body: JSON.stringify({ state: nextState }), + }) + + if (!response.ok) { + throw new Error('like_request_failed') + } + + onLike?.(item) + } catch { + setLiked(!nextState) + setLikeCount((current) => Math.max(0, current + (nextState ? -1 : 1))) + } finally { + setLikeBusy(false) + } + } + + const handleDownload = async (event) => { + event.preventDefault() + if (!item.id || downloadBusy) return + + setDownloadBusy(true) + try { + const link = document.createElement('a') + link.href = downloadHref + link.rel = 'noopener noreferrer' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } catch { + window.open(downloadHref, '_blank', 'noopener,noreferrer') + } finally { + setDownloadBusy(false) + } + } + + if (variant === 'embed') { + return ( + + ) + } + + return ( +
+
+ + {cardLabel} + + +
+
+ + {title} { + event.currentTarget.src = IMAGE_FALLBACK + }} + /> + +
+ + {showActions && ( +
+ + + + + + + + + + + +
+ )} + +
+

+ {title} +

+ + {showAuthor ? ( +
+ + {`Avatar { + event.currentTarget.src = AVATAR_FALLBACK + }} + /> + + + {author} + {username && @{username}} + + {showStats && metadataLine && ( + + {metadataLine} + + )} + + +
+ ) : showStats && metadataLine ? ( +
+ {metadataLine} +
+ ) : null} +
+
+
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkCardMini.jsx b/resources/js/components/artwork/ArtworkCardMini.jsx deleted file mode 100644 index 77365b32..00000000 --- a/resources/js/components/artwork/ArtworkCardMini.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp' - -export default function ArtworkCardMini({ item }) { - if (!item?.url) return null - - return ( - - ) -} diff --git a/resources/js/components/artwork/ArtworkGallery.jsx b/resources/js/components/artwork/ArtworkGallery.jsx new file mode 100644 index 00000000..f44e3361 --- /dev/null +++ b/resources/js/components/artwork/ArtworkGallery.jsx @@ -0,0 +1,58 @@ +import React from 'react' +import ArtworkCard from './ArtworkCard' + +function cx(...parts) { + return parts.filter(Boolean).join(' ') +} + +function getArtworkKey(item, index) { + if (item?.id) return item.id + if (item?.title || item?.name || item?.author) { + return `${item.title || item.name || 'artwork'}-${item.author || item.author_name || item.uname || 'artist'}-${index}` + } + + return `artwork-${index}` +} + +export default function ArtworkGallery({ + items, + layout = 'grid', + compact = false, + showStats = true, + showAuthor = true, + className = '', + cardClassName = '', + limit, + containerProps = {}, + resolveCardProps, + children, +}) { + if (!Array.isArray(items) || items.length === 0) return null + + const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items + const baseClassName = layout === 'masonry' + ? 'grid gap-6' + : 'grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4' + + return ( +
+ {visibleItems.map((item, index) => { + const cardProps = resolveCardProps?.(item, index) || {} + const { className: resolvedClassName = '', ...restCardProps } = cardProps + + return ( + + ) + })} + {children} +
+ ) +} diff --git a/resources/js/components/artwork/ArtworkGalleryGrid.jsx b/resources/js/components/artwork/ArtworkGalleryGrid.jsx new file mode 100644 index 00000000..4202bcd0 --- /dev/null +++ b/resources/js/components/artwork/ArtworkGalleryGrid.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import ArtworkGallery from './ArtworkGallery' + +function cx(...parts) { + return parts.filter(Boolean).join(' ') +} + +export default function ArtworkGalleryGrid({ + items, + compact = false, + showStats = true, + showAuthor = true, + limit, + className = '', + cardClassName = '', +}) { + if (!Array.isArray(items) || items.length === 0) return null + + const visibleItems = typeof limit === 'number' ? items.slice(0, limit) : items + + return ( + + ) +} diff --git a/resources/js/components/artwork/ArtworkRelated.jsx b/resources/js/components/artwork/ArtworkRelated.jsx index 8b10d810..00ce2fac 100644 --- a/resources/js/components/artwork/ArtworkRelated.jsx +++ b/resources/js/components/artwork/ArtworkRelated.jsx @@ -1,6 +1,5 @@ import React from 'react' - -const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp' +import ArtworkGalleryGrid from './ArtworkGalleryGrid' export default function ArtworkRelated({ related }) { if (!Array.isArray(related) || related.length === 0) return null @@ -9,36 +8,11 @@ export default function ArtworkRelated({ related }) {

Related Artworks

- +
) } diff --git a/resources/js/components/artwork/CreatorSpotlight.jsx b/resources/js/components/artwork/CreatorSpotlight.jsx index 25668ca0..ae70a03a 100644 --- a/resources/js/components/artwork/CreatorSpotlight.jsx +++ b/resources/js/components/artwork/CreatorSpotlight.jsx @@ -1,4 +1,5 @@ import React, { useMemo, useState } from 'react' +import NovaConfirmDialog from '../ui/NovaConfirmDialog' const AVATAR_FALLBACK = 'https://files.skinbase.org/default/missing_sq.webp' @@ -23,6 +24,8 @@ function toCard(item) { export default function CreatorSpotlight({ artwork, presentSq, related = [] }) { const [following, setFollowing] = useState(Boolean(artwork?.viewer?.is_following_author)) const [followersCount, setFollowersCount] = useState(Number(artwork?.user?.followers_count || 0)) + const [confirmOpen, setConfirmOpen] = useState(false) + const [pendingFollowState, setPendingFollowState] = useState(null) const user = artwork?.user || {} const authorName = user.name || user.username || 'Artist' @@ -43,13 +46,7 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) { return source.slice(0, 12).map(toCard) }, [related, authorName, artwork?.canonical_url]) - const onToggleFollow = async () => { - if (following) { - const confirmed = window.confirm(`Unfollow @${user.username || authorName}?`) - if (!confirmed) return - } - - const nextState = !following + const persistFollowState = async (nextState) => { setFollowing(nextState) try { const response = await fetch(`/api/users/${user.id}/follow`, { @@ -73,99 +70,135 @@ export default function CreatorSpotlight({ artwork, presentSq, related = [] }) { } } + const onToggleFollow = async () => { + const nextState = !following + if (!nextState) { + setPendingFollowState(nextState) + setConfirmOpen(true) + return + } + + await persistFollowState(nextState) + } + + const onConfirmUnfollow = async () => { + if (pendingFollowState === null) return + setConfirmOpen(false) + await persistFollowState(pendingFollowState) + setPendingFollowState(null) + } + + const onCloseConfirm = () => { + setConfirmOpen(false) + setPendingFollowState(null) + } + return ( -
- {/* Avatar + info — stacked for sidebar */} -
- - {authorName} { - event.currentTarget.src = AVATAR_FALLBACK - }} - /> - - - - {authorName} - - {user.username &&

@{user.username}

} -

- {followersCount.toLocaleString()} Followers -

- - {/* Profile + Follow buttons */} - - {/* More from creator rail */} - {creatorItems.length > 0 && ( -
+ + {/* More from creator rail */} + {creatorItems.length > 0 && ( +
+
+

More from {authorName}

+ + + + + +
+
+ {creatorItems.slice(0, 3).map((item, idx) => ( + +
+ {item.title +
+
+
+ )} + + + + ) } diff --git a/resources/js/components/gallery/ArtworkCard.jsx b/resources/js/components/gallery/ArtworkCard.jsx deleted file mode 100644 index 923288cd..00000000 --- a/resources/js/components/gallery/ArtworkCard.jsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -function slugify(str) { - return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); -} - -/** - * React version of resources/views/components/artwork-card.blade.php - * Keeps identical HTML structure so existing CSS (nova-card, nova-card-media, etc.) applies. - */ -export default function ArtworkCard({ art, loading = 'lazy', fetchPriority = null }) { - const imgRef = useRef(null); - const mediaRef = useRef(null); - - const title = (art.name || art.title || 'Untitled artwork').trim(); - const author = (art.uname || art.author_name || art.author || 'Skinbase').trim(); - const username = (art.username || art.uname || '').trim(); - const category = (art.category_name || art.category || '').trim(); - - const likes = art.likes ?? art.favourites ?? 0; - const views = art.views ?? art.views_count ?? art.view_count ?? 0; - const downloads = art.downloads ?? art.downloads_count ?? art.download_count ?? 0; - - const imgSrc = art.thumb || art.thumb_url || art.thumbnail_url || '/images/placeholder.jpg'; - const imgSrcset = art.thumb_srcset || imgSrc; - - const cardUrl = art.url || (art.id ? `/art/${art.id}/${slugify(title)}` : '#'); - const authorUrl = username ? `/@${username.toLowerCase()}` : null; - // Use pre-computed CDN URL from the server; JS fallback mirrors AvatarUrl::default() - const cdnBase = 'https://files.skinbase.org'; - const avatarSrc = art.avatar_url || `${cdnBase}/default/avatar_default.webp`; - - const hasDimensions = Number(art.width) > 0 && Number(art.height) > 0; - const aspectRatio = hasDimensions ? Number(art.width) / Number(art.height) : null; - - // Activate blur-preview class once image has decoded (mirrors nova.js behaviour). - // If the server didn't supply dimensions (old artworks with width=0/height=0), - // read naturalWidth/naturalHeight from the loaded image and imperatively set - // the container's aspect-ratio so the masonry ResizeObserver picks up real proportions. - useEffect(() => { - const img = imgRef.current; - const media = mediaRef.current; - if (!img) return; - - const markLoaded = () => { - img.classList.add('is-loaded'); - // If no server-side dimensions, apply real ratio from the decoded image - if (media && !hasDimensions && img.naturalWidth > 0 && img.naturalHeight > 0) { - media.style.aspectRatio = `${img.naturalWidth} / ${img.naturalHeight}`; - } - }; - - if (img.complete && img.naturalWidth > 0) { markLoaded(); return; } - img.addEventListener('load', markLoaded, { once: true }); - img.addEventListener('error', markLoaded, { once: true }); - }, []); - - // Span 2 columns for panoramic images (AR > 2.0) in Photography or Wallpapers categories. - // These slugs match the root categories; name-matching is kept as fallback. - const wideCategories = ['photography', 'wallpapers', 'photography-digital', 'wallpaper']; - const wideCategoryNames = ['photography', 'wallpapers']; - const catSlug = (art.category_slug || '').toLowerCase(); - const catName = (art.category_name || '').toLowerCase(); - const isWideEligible = - aspectRatio !== null && - aspectRatio > 2.0 && - (wideCategories.includes(catSlug) || wideCategoryNames.includes(catName)); - - const articleStyle = isWideEligible ? { gridColumn: 'span 2' } : {}; - const aspectStyle = hasDimensions ? { aspectRatio: `${art.width} / ${art.height}` } : {}; - // Image always fills the container absolutely – the container's height is - // driven by aspect-ratio (capped by CSS max-height). Using absolute - // positioning means width/height are always 100% of the capped box, so - // object-cover crops top/bottom instead of leaving dark gaps. - const imgClass = [ - 'nova-card-main-image', - 'absolute inset-0 h-full w-full object-cover', - 'transition-[transform,filter] duration-150 ease-out group-hover:scale-[1.03]', - loading !== 'eager' ? 'blur-sm scale-[1.02] data-blur-preview' : '', - ].join(' '); - - const metaParts = []; - if (art.resolution) metaParts.push(art.resolution); - else if (hasDimensions) metaParts.push(`${art.width}×${art.height}`); - if (category) metaParts.push(category); - if (art.license) metaParts.push(art.license); - - return ( -
- - {/* nova-card-media: height driven by aspect-ratio, capped by MasonryGallery.css max-height. - w-full prevents browsers shrinking the width when max-height overrides aspect-ratio. */} -
-
- -
- {likes} - {views} - {downloads} -
- - {title} - - {/* Overlay caption */} -
-
{title}
-
- - {`Avatar - - {author} - {username && ( - @{username} - )} - - - ❤ {likes} · 👁 {views} · ⬇ {downloads} -
- {metaParts.length > 0 && ( -
- {metaParts.join(' • ')} -
- )} -
-
- - {title} by {author} -
- - {/* ── Quick actions: top-right, shown on card hover via CSS ─────── */} - - -
- ); -} diff --git a/resources/views/_legacy/home/uploads.blade.php b/resources/views/_legacy/home/uploads.blade.php index 6da8dff0..9289c5fc 100644 --- a/resources/views/_legacy/home/uploads.blade.php +++ b/resources/views/_legacy/home/uploads.blade.php @@ -3,10 +3,14 @@
@php $t = \App\Services\ThumbnailPresenter::present($upload, 'md'); + $card = [ + 'id' => $t['id'] ?? null, + 'title' => $t['title'] ?? 'Artwork', + 'thumb' => $t['url'] ?? null, + 'thumb_srcset' => $t['srcset'] ?? null, + ]; @endphp - - {{ $t['title'] }} - +
@endforeach
diff --git a/resources/views/_legacy/latest-artworks.blade.php b/resources/views/_legacy/latest-artworks.blade.php index c12e2825..d7c4f05f 100644 --- a/resources/views/_legacy/latest-artworks.blade.php +++ b/resources/views/_legacy/latest-artworks.blade.php @@ -14,18 +14,7 @@