refactor: unify artwork card rendering
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<int, string>
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <a> inside <a> — navigation handled via onClick
|
||||
<div
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={artwork.title}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="group flex gap-3 rounded-xl border border-white/[0.08] bg-black/30 p-3 hover:border-sky-500/30 transition-colors cursor-pointer"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-20 h-16 rounded-lg overflow-hidden shrink-0 bg-white/5">
|
||||
{artwork.thumb_url ? (
|
||||
<img
|
||||
src={artwork.thumb_url}
|
||||
alt={artwork.title}
|
||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-600">
|
||||
<i className="fa-solid fa-image" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex flex-col justify-center min-w-0">
|
||||
<p className="text-sm font-medium text-white/90 truncate">{artwork.title}</p>
|
||||
<a
|
||||
href={authorUrl}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs text-slate-400 hover:text-sky-400 transition-colors mt-0.5 truncate"
|
||||
>
|
||||
<i className="fa-solid fa-user-circle fa-fw mr-1 opacity-60" />
|
||||
by {artwork.author.name || `@${artwork.author.username}`}
|
||||
</a>
|
||||
<span className="text-[10px] text-slate-600 mt-1 uppercase tracking-wider">Artwork</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function slugify(str) {
|
||||
return (str || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
@@ -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 && (
|
||||
<EmbeddedArtworkCard artwork={postData.artwork} />
|
||||
<ArtworkCard artwork={postData.artwork} variant="embed" showActions={false} showStats={false} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
|
||||
<>
|
||||
<section className="rounded-xl border border-nova-700 bg-panel p-5 shadow-lg shadow-deep/30">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Author</h2>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-14 w-14 rounded-full border border-nova-600 object-cover bg-nova-900/50 shadow-md shadow-deep/30"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="min-w-0">
|
||||
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
<div className="min-w-0">
|
||||
<a href={profileUrl} className="block truncate text-base font-semibold text-white hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="truncate text-xs text-soft">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs text-soft">{followersCount.toLocaleString()} followers</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
|
||||
onClick={onToggleFollow}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
|
||||
</svg>
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<a
|
||||
href={profileUrl}
|
||||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800 transition"
|
||||
>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-3 py-2 text-sm font-semibold transition ${following ? 'border border-nova-600 text-white hover:bg-nova-800' : 'bg-accent text-deep hover:brightness-110'}`}
|
||||
onClick={onToggleFollow}
|
||||
>
|
||||
{following ? (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z" />
|
||||
</svg>
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
Follow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || user.name || 'this creator'} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
409
resources/js/components/artwork/ArtworkCard.jsx
Normal file
409
resources/js/components/artwork/ArtworkCard.jsx
Normal file
@@ -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 (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c-4.97-3.12-8.25-6.16-8.25-10.03A4.72 4.72 0 0 1 8.5 5.5c1.5 0 2.93.7 3.84 1.92A4.8 4.8 0 0 1 16.18 5.5a4.72 4.72 0 0 1 4.82 4.72c0 3.87-3.28 6.91-8.25 10.03Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function DownloadIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3.75v10.5" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 10.5 3.75 3.75 3.75-3.75" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 18.75h15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ViewIcon(props) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true" {...props}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12s3.75-6.75 9.75-6.75S21.75 12 21.75 12 18 18.75 12 18.75 2.25 12 2.25 12Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 14.75A2.75 2.75 0 1 0 12 9.25a2.75 2.75 0 0 0 0 5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionLink({ href, label, children, onClick }) {
|
||||
return (
|
||||
<a
|
||||
href={href || '#'}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ label, children, onClick }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-white/10 bg-white/10 text-white/90 shadow-[0_14px_36px_rgba(2,6,23,0.38)] backdrop-blur-md transition duration-200 hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<article
|
||||
className={cx('group overflow-hidden rounded-xl border border-white/[0.08] bg-black/30 transition-colors hover:border-sky-500/30', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<a
|
||||
href={href}
|
||||
className="flex gap-3 p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
>
|
||||
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-lg bg-white/5">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = IMAGE_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white/90">{title}</p>
|
||||
{showAuthor && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">
|
||||
{authorHref ? (
|
||||
<span>
|
||||
by {author} <span className="text-slate-500">@{username}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>by {author}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 truncate text-[10px] uppercase tracking-wider text-slate-600">
|
||||
{contentType || 'Artwork'}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={cx('group relative', articleClassName, className)}
|
||||
style={articleStyle}
|
||||
{...articleData}
|
||||
>
|
||||
<div className={cx('relative overflow-hidden rounded-[1.6rem] border border-white/8 bg-slate-950/80 shadow-[0_18px_60px_rgba(2,6,23,0.45)] transition duration-300 ease-out group-hover:-translate-y-1 group-hover:scale-[1.02] group-hover:border-white/14 group-hover:shadow-[0_24px_80px_rgba(8,47,73,0.5)] group-focus-within:-translate-y-1 group-focus-within:scale-[1.02] group-focus-within:border-sky-200/40', frameClassName)}>
|
||||
<a
|
||||
href={href}
|
||||
aria-label={`Open artwork: ${cardLabel}`}
|
||||
className="absolute inset-0 z-10 rounded-[inherit] cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/80"
|
||||
>
|
||||
<span className="sr-only">{cardLabel}</span>
|
||||
</a>
|
||||
|
||||
<div className={cx('relative overflow-hidden bg-slate-900', aspectClass, mediaClassName)} style={mediaStyle}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(148,163,184,0.28),_transparent_52%),linear-gradient(180deg,rgba(15,23,42,0.08),rgba(15,23,42,0.42))]" />
|
||||
|
||||
<img
|
||||
src={image}
|
||||
srcSet={imageSrcSet || undefined}
|
||||
sizes={imageSizes || undefined}
|
||||
alt={title}
|
||||
width={imageWidth || undefined}
|
||||
height={imageHeight || undefined}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
fetchPriority={fetchPriority || undefined}
|
||||
className={cx('h-full w-full object-cover transition duration-500 group-hover:scale-110 group-focus-within:scale-110', imageClassName)}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = IMAGE_FALLBACK
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/38 to-transparent opacity-90 transition duration-300 md:opacity-45 md:group-hover:opacity-100 md:group-focus-within:opacity-100" />
|
||||
|
||||
{showActions && (
|
||||
<div className="absolute right-3 top-3 z-20 flex translate-y-2 gap-2 opacity-100 transition duration-200 md:opacity-0 md:group-hover:translate-y-0 md:group-hover:opacity-100 md:group-focus-within:translate-y-0 md:group-focus-within:opacity-100">
|
||||
<ActionButton label={liked ? 'Unlike artwork' : 'Like artwork'} onClick={handleLike}>
|
||||
<HeartIcon className={cx('h-4 w-4 transition-transform duration-200', liked ? 'fill-current text-rose-300' : '', likeBusy ? 'scale-90' : '')} />
|
||||
</ActionButton>
|
||||
|
||||
<ActionLink href={downloadHref} label={downloadBusy ? 'Downloading artwork' : 'Download artwork'} onClick={handleDownload}>
|
||||
<DownloadIcon className={cx('h-4 w-4', downloadBusy ? 'animate-pulse text-emerald-300' : '')} />
|
||||
</ActionLink>
|
||||
|
||||
<ActionLink href={href} label="View artwork">
|
||||
<ViewIcon className="h-4 w-4" />
|
||||
</ActionLink>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="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-within:opacity-100">
|
||||
<h3 className={cx('truncate font-semibold text-white', titleClass)}>
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{showAuthor ? (
|
||||
<div className="mt-1 flex items-start justify-between gap-3 text-xs text-white/80">
|
||||
<span className="flex min-w-0 items-start gap-3">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`Avatar of ${author}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="h-9 w-9 shrink-0 rounded-full object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white/90">
|
||||
{author}
|
||||
{username && <span className="text-[11px] text-white/60"> @{username}</span>}
|
||||
</span>
|
||||
{showStats && metadataLine && (
|
||||
<span className="mt-0.5 block truncate text-[11px] text-white/70">
|
||||
{metadataLine}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
) : showStats && metadataLine ? (
|
||||
<div className="mt-1 text-[11px] text-white/70">
|
||||
{metadataLine}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<article className="group min-w-[14rem] shrink-0 snap-start overflow-hidden rounded-xl border border-white/[0.06] bg-white/[0.03] transition-all duration-200 hover:-translate-y-0.5 hover:border-white/[0.1] hover:shadow-xl hover:shadow-black/30">
|
||||
<a href={item.url} className="block">
|
||||
<div className="relative aspect-[4/3] overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || FALLBACK_MD}
|
||||
srcSet={item.thumbSrcSet || undefined}
|
||||
sizes="256px"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_MD
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/40 to-transparent opacity-60" />
|
||||
</div>
|
||||
<div className="px-3.5 py-3">
|
||||
<h3 className="truncate text-sm font-semibold text-white/90">{item.title || 'Untitled'}</h3>
|
||||
<p className="mt-0.5 truncate text-xs text-white/40">by {item.author || 'Artist'}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
58
resources/js/components/artwork/ArtworkGallery.jsx
Normal file
58
resources/js/components/artwork/ArtworkGallery.jsx
Normal file
@@ -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 (
|
||||
<div className={cx(baseClassName, className)} {...containerProps}>
|
||||
{visibleItems.map((item, index) => {
|
||||
const cardProps = resolveCardProps?.(item, index) || {}
|
||||
const { className: resolvedClassName = '', ...restCardProps } = cardProps
|
||||
|
||||
return (
|
||||
<ArtworkCard
|
||||
key={getArtworkKey(item, index)}
|
||||
artwork={item}
|
||||
compact={compact}
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(cardClassName, resolvedClassName)}
|
||||
{...restCardProps}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
resources/js/components/artwork/ArtworkGalleryGrid.jsx
Normal file
32
resources/js/components/artwork/ArtworkGalleryGrid.jsx
Normal file
@@ -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 (
|
||||
<ArtworkGallery
|
||||
items={visibleItems}
|
||||
layout="grid"
|
||||
compact={compact}
|
||||
showStats={showStats}
|
||||
showAuthor={showAuthor}
|
||||
className={cx(className)}
|
||||
cardClassName={cardClassName}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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 }) {
|
||||
<section className="mt-12">
|
||||
<h2 className="text-lg font-semibold text-white">Related Artworks</h2>
|
||||
|
||||
<div className="mt-5 flex snap-x snap-mandatory gap-4 overflow-x-auto pb-2 lg:grid lg:grid-cols-4 lg:gap-5 lg:overflow-visible">
|
||||
{related.slice(0, 12).map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="group min-w-[75%] snap-start overflow-hidden rounded-xl border border-nova-700 bg-panel transition lg:min-w-0 lg:hover:border-nova-500"
|
||||
>
|
||||
<a href={item.url} className="block">
|
||||
<div className="relative aspect-video bg-deep">
|
||||
<img
|
||||
src={item.thumb || FALLBACK_MD}
|
||||
srcSet={item.thumb_srcset || undefined}
|
||||
sizes="(min-width: 1024px) 25vw, 75vw"
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 lg:group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = FALLBACK_MD
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-deep/25 to-transparent lg:opacity-0 lg:transition lg:duration-300 lg:group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<h3 className="truncate text-sm font-semibold text-white">{item.title}</h3>
|
||||
<p className="truncate text-xs text-soft">by {item.author || 'Artist'}</p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<ArtworkGalleryGrid
|
||||
items={related.slice(0, 8)}
|
||||
compact
|
||||
className="mt-5 xl:grid-cols-4"
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
title="View profile"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
<>
|
||||
<section className="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-5">
|
||||
{/* Avatar + info — stacked for sidebar */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<a href={profileUrl} className="group">
|
||||
<img
|
||||
src={avatar}
|
||||
alt={authorName}
|
||||
className="h-16 w-16 rounded-full border-2 border-white/10 object-cover shadow-lg shadow-black/40 transition-transform duration-200 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = AVATAR_FALLBACK
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
<a href={profileUrl} className="mt-3 block text-base font-bold text-white transition-colors hover:text-accent">
|
||||
{authorName}
|
||||
</a>
|
||||
{user.username && <p className="text-xs text-white/40">@{user.username}</p>}
|
||||
<p className="mt-1 text-xs font-medium text-white/30">
|
||||
{followersCount.toLocaleString()} Followers
|
||||
</p>
|
||||
|
||||
{/* Profile + Follow buttons */}
|
||||
<div className="mt-4 flex w-full gap-2">
|
||||
<a
|
||||
href={profileUrl}
|
||||
title="View profile"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-xl border border-white/[0.08] bg-white/[0.04] px-3 py-2.5 text-sm font-medium text-white/70 transition-all hover:border-white/[0.15] hover:bg-white/[0.07] hover:text-white"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
Profile
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={following ? 'Unfollow creator' : 'Follow creator'}
|
||||
onClick={onToggleFollow}
|
||||
className={[
|
||||
'flex flex-1 items-center justify-center gap-1.5 rounded-xl px-3 py-2.5 text-sm font-semibold transition-all duration-200',
|
||||
following
|
||||
? 'border border-white/[0.08] bg-white/[0.04] text-white/70 hover:bg-white/[0.07]'
|
||||
: 'bg-accent text-deep shadow-lg shadow-accent/20 hover:brightness-110 hover:shadow-accent/30',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
{following ? 'Following' : 'Follow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* More from creator rail */}
|
||||
{creatorItems.length > 0 && (
|
||||
<div className="mt-5 border-t border-white/[0.06] pt-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-white/80">More from {authorName}</h3>
|
||||
<a href={profileUrl} className="text-white/30 transition-colors hover:text-white/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{creatorItems.slice(0, 3).map((item, idx) => (
|
||||
<a key={`${item.id || item.url}-${idx}`} href={item.url} className="group/mini relative overflow-hidden rounded-xl">
|
||||
<div className="aspect-square overflow-hidden bg-deep">
|
||||
<img
|
||||
src={item.thumb || AVATAR_FALLBACK}
|
||||
alt={item.title || 'Artwork'}
|
||||
className="h-full w-full object-cover transition duration-300 group-hover/mini:scale-[1.06]"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-1.5 right-1.5 flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="h-3 w-3 text-rose-400">
|
||||
<path d="m9.653 16.915-.005-.003-.019-.01a20.759 20.759 0 0 1-1.162-.682 22.045 22.045 0 0 1-2.582-1.9C4.045 12.733 2 10.352 2 7.5a4.5 4.5 0 0 1 8-2.828A4.5 4.5 0 0 1 18 7.5c0 2.852-2.044 5.233-3.885 6.82a22.049 22.049 0 0 1-3.744 2.582l-.019.01-.005.003h-.002a.723.723 0 0 1-.69 0h-.002Z" />
|
||||
</svg>
|
||||
<span className="text-[10px] font-bold text-white drop-shadow">
|
||||
{item.likes ? formatCount(item.likes) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<NovaConfirmDialog
|
||||
open={confirmOpen}
|
||||
title="Unfollow creator?"
|
||||
message={`You will stop seeing updates from @${user.username || authorName} in your following feed.`}
|
||||
confirmLabel="Unfollow"
|
||||
cancelLabel="Keep following"
|
||||
confirmTone="danger"
|
||||
onConfirm={onConfirmUnfollow}
|
||||
onClose={onCloseConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<article
|
||||
className={`nova-card gallery-item artwork relative${isWideEligible ? ' nova-card--wide' : ''}`}
|
||||
style={articleStyle}
|
||||
data-art-id={art.id}
|
||||
data-art-url={cardUrl}
|
||||
data-art-title={title}
|
||||
data-art-img={imgSrc}
|
||||
>
|
||||
<a
|
||||
href={cardUrl}
|
||||
className="group relative block overflow-hidden rounded-2xl ring-1 ring-white/5 bg-black/20
|
||||
shadow-lg shadow-black/40
|
||||
transition-all duration-150 ease-out
|
||||
hover:scale-[1.02] hover:-translate-y-px hover:ring-white/15
|
||||
hover:shadow-[0_8px_30px_rgba(0,0,0,0.6),0_0_0_1px_rgba(255,255,255,0.08)]
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={mediaRef}
|
||||
className="nova-card-media relative w-full overflow-hidden bg-neutral-900"
|
||||
style={aspectStyle}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 via-white/5 to-transparent pointer-events-none" />
|
||||
|
||||
<div className="pointer-events-none absolute right-2 top-2 z-20 flex items-center gap-1.5 rounded-full border border-white/10 bg-black/45 px-2 py-1 text-[10px] text-white/85 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
|
||||
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-heart text-[9px] text-rose-300" />{likes}</span>
|
||||
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-eye text-[9px] text-sky-300" />{views}</span>
|
||||
<span className="inline-flex items-center gap-1"><i className="fa-solid fa-download text-[9px] text-emerald-300" />{downloads}</span>
|
||||
</div>
|
||||
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imgSrc}
|
||||
srcSet={imgSrcset}
|
||||
sizes="(max-width: 768px) 50vw, (max-width: 1280px) 33vw, 20vw"
|
||||
loading={loading}
|
||||
decoding={loading === 'eager' ? 'sync' : 'async'}
|
||||
fetchPriority={fetchPriority || undefined}
|
||||
alt={title}
|
||||
width={hasDimensions ? art.width : undefined}
|
||||
height={hasDimensions ? art.height : undefined}
|
||||
className={imgClass}
|
||||
data-blur-preview={loading !== 'eager' ? '' : undefined}
|
||||
/>
|
||||
|
||||
{/* Overlay caption */}
|
||||
<div className="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 className="truncate text-sm font-semibold text-white">{title}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-3 text-xs text-white/80">
|
||||
<span className="truncate flex items-center gap-2">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={`Avatar of ${author}`}
|
||||
className="w-6 h-6 shrink-0 rounded-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="truncate">
|
||||
<span>{author}</span>
|
||||
{username && (
|
||||
<span className="text-white/60"> @{username}</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0">❤ {likes} · 👁 {views} · ⬇ {downloads}</span>
|
||||
</div>
|
||||
{metaParts.length > 0 && (
|
||||
<div className="mt-1 text-[11px] text-white/70">
|
||||
{metaParts.join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="sr-only">{title} by {author}</span>
|
||||
</a>
|
||||
|
||||
{/* ── Quick actions: top-right, shown on card hover via CSS ─────── */}
|
||||
<div className="nb-card-actions" aria-hidden="true">
|
||||
<button
|
||||
type="button"
|
||||
className="nb-card-action-btn"
|
||||
title="Favourite"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Favourite action – wired to API in future iteration
|
||||
}}
|
||||
>
|
||||
♥
|
||||
</button>
|
||||
<a
|
||||
href={`${cardUrl}?download=1`}
|
||||
className="nb-card-action-btn"
|
||||
title="Download"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
⬇
|
||||
</a>
|
||||
<a
|
||||
href={cardUrl}
|
||||
className="nb-card-action-btn"
|
||||
title="Quick view"
|
||||
tabIndex={-1}
|
||||
>
|
||||
👁
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
<div class="thumb-card effect2">
|
||||
@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
|
||||
<a href="/art/{{ $t['id'] }}/{{ Str::slug($t['title'] ?: 'artwork') }}" title="{{ $t['title'] }}" class="thumb-link">
|
||||
<img src="{{ $t['url'] }}" @if(!empty($t['srcset'])) srcset="{{ $t['srcset'] }}" @endif alt="{{ $t['title'] }}" class="img-responsive">
|
||||
</a>
|
||||
<x-artwork-card :art="$card" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div> <!-- end .gallery-grid -->
|
||||
|
||||
@@ -14,18 +14,7 @@
|
||||
<div class="gallery-grid">
|
||||
@if($artworks)
|
||||
@foreach($artworks as $art)
|
||||
<div class="thumb-card effect2">
|
||||
@if (!empty($art->category_name))
|
||||
<div class="ribbon gid_{{ $art->gid_num ?? 0 }}" title="{{ $art->category_name }}"><span>{{ $art->category_name }}</span></div>
|
||||
@endif
|
||||
<a href="/art/{{ $art->id }}/{{ Str::slug($art->name ?? '') }}" class="thumb-link" title="{{ $art->name }}">
|
||||
<img src="{{ $art->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}" @if(!empty($art->thumb_srcset)) srcset="{{ $art->thumb_srcset }}" @endif alt="{{ $art->name }}" class="img-responsive" loading="lazy" decoding="async">
|
||||
</a>
|
||||
<div class="thumb-meta">
|
||||
<div class="thumb-title">{{ $art->name }}</div>
|
||||
<div class="thumb-author text-muted">by {{ $art->uname ?? 'Unknown' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<x-artwork-card :art="$art" />
|
||||
@endforeach
|
||||
@else
|
||||
<p class="text-muted">No artworks found.</p>
|
||||
|
||||
@@ -1,55 +1,79 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Today in History', 'url' => route('legacy.today_in_history')],
|
||||
]);
|
||||
|
||||
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : [])->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? null,
|
||||
'slug' => $art->slug ?? null,
|
||||
'url' => $art->url ?? $art->art_url ?? null,
|
||||
'thumb' => $art->thumb_url ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'content_type_name' => $art->content_type_name ?? '',
|
||||
'content_type_slug' => $art->content_type_slug ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
])->values();
|
||||
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="History"
|
||||
:title="$page_title ?? 'Today in History'"
|
||||
icon="fa-calendar-days"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
:description="'Featured artworks uploaded on <span class="text-white/80 font-medium">' . e($todayLabel ?? now()->format('F j')) . '</span> in past years.'"
|
||||
headerClass="pb-6"
|
||||
/>
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">History</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title ?? 'Today in History' }}</h1>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Featured artworks uploaded on
|
||||
<span class="text-white/80 font-medium">{{ $todayLabel ?? now()->format('F j') }}</span>
|
||||
in past years.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ── Gallery ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
@if($artworks && $artworks->count())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
@foreach($artworks as $ar)
|
||||
<a href="{{ $ar->art_url ?? ('/art/' . $ar->id) }}"
|
||||
class="group relative block overflow-hidden rounded-xl ring-1 ring-white/5 bg-black/20 shadow-md transition-all duration-200 hover:-translate-y-0.5">
|
||||
<div class="relative aspect-square overflow-hidden bg-neutral-900">
|
||||
<img src="{{ $ar->thumb_url ?? 'https://files.skinbase.org/default/missing_md.webp' }}"
|
||||
alt="{{ $ar->name ?? '' }}"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.06]"
|
||||
onerror="this.src='https://files.skinbase.org/default/missing_md.webp'">
|
||||
{{-- Title overlay on hover --}}
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-2 py-2
|
||||
opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<p class="truncate text-xs font-medium text-white">{{ $ar->name ?? 'Untitled' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Pagination --}}
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}
|
||||
</div>
|
||||
<section class="px-6 pt-8 md:px-10">
|
||||
@if ($artworks && $artworks->count())
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||
data-gallery-type="today-in-history"
|
||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||
data-limit="36"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
@else
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-16 text-center">
|
||||
<p class="text-4xl mb-4">📅</p>
|
||||
<p class="text-white/60 text-sm">No featured artworks found for this day in history.</p>
|
||||
<p class="text-white/30 text-xs mt-1">Check back tomorrow!</p>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No featured artworks were found for {{ $todayLabel ?? now()->format('F j') }}.</p>
|
||||
<p class="text-white/25 text-xs mt-1">When historical features exist for this date, they will appear here.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
@@ -1,32 +1,79 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title, 'url' => route('legacy.top_favourites')],
|
||||
]);
|
||||
|
||||
$galleryArtworks = collect(method_exists($artworks, 'items') ? $artworks->items() : [])->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? null,
|
||||
'slug' => $art->slug ?? null,
|
||||
'url' => $art->url ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_url' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'content_type_name' => $art->content_type_name ?? '',
|
||||
'content_type_slug' => $art->content_type_slug ?? '',
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'favourites' => (int) ($art->favourites ?? $art->num ?? 0),
|
||||
])->values();
|
||||
|
||||
$galleryNextPageUrl = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
|
||||
@endphp
|
||||
|
||||
@section('content')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
<header class="page-heading">
|
||||
<h1 class="page-header">{{ $page_title }}</h1>
|
||||
<p>Most popular artworks which users added in their favourites</p>
|
||||
</header>
|
||||
</div>
|
||||
<x-nova-page-header
|
||||
section="Favourites"
|
||||
:title="$page_title"
|
||||
icon="fa-heart"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
description="Most popular artworks which users added in their favourites."
|
||||
headerClass="pb-6"
|
||||
/>
|
||||
|
||||
<section class="px-6 pt-8 md:px-10">
|
||||
@if ($artworks && $artworks->count())
|
||||
<div class="container_photo gallery_box">
|
||||
<div class="grid-sizer"></div>
|
||||
@foreach ($artworks as $art)
|
||||
@include('legacy::_artwork_card', ['art' => $art])
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="paginationMenu text-center">
|
||||
{{ $artworks->withQueryString()->links('pagination::bootstrap-4') }}
|
||||
</div>
|
||||
<div
|
||||
data-react-masonry-gallery
|
||||
data-artworks="{{ json_encode($galleryArtworks) }}"
|
||||
data-gallery-type="top-favourites"
|
||||
@if ($galleryNextPageUrl) data-next-page-url="{{ $galleryNextPageUrl }}" @endif
|
||||
data-limit="21"
|
||||
class="min-h-32"
|
||||
></div>
|
||||
@else
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>No Artworks Yet</strong></div>
|
||||
<div class="panel-body">
|
||||
<p>Once uploads arrive they will appear here. Check back soon.</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
||||
<p class="text-white/40 text-sm">No artworks have been favourited yet.</p>
|
||||
<p class="text-white/25 text-xs mt-1">Once members start saving artworks, they will appear here.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@push('styles')
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(5, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 1600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (min-width: 2600px) {
|
||||
[data-nova-gallery] [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] { grid-template-columns: repeat(7, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||
@endpush
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@php
|
||||
if (isset($art) && (is_array($art) || $art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection)) {
|
||||
$first = null;
|
||||
if (is_array($art)) {
|
||||
if (is_array($art) && !\Illuminate\Support\Arr::isAssoc($art)) {
|
||||
$first = reset($art);
|
||||
} elseif ($art instanceof Illuminate\Support\Collection || $art instanceof Illuminate\Database\Eloquent\Collection) {
|
||||
$first = $art->first();
|
||||
@@ -17,6 +17,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (is_array($art) && \Illuminate\Support\Arr::isAssoc($art)) {
|
||||
$art = (object) $art;
|
||||
}
|
||||
|
||||
$title = trim((string) ($art->name ?? $art->title ?? 'Untitled artwork'));
|
||||
|
||||
$author = trim((string) (
|
||||
@@ -34,6 +38,25 @@
|
||||
?? ''
|
||||
));
|
||||
|
||||
$rawContentType = trim((string) (
|
||||
$art->content_type_name
|
||||
?? $art->content_type
|
||||
?? $art->content_type_slug
|
||||
?? ''
|
||||
));
|
||||
|
||||
$contentType = match (strtolower($rawContentType)) {
|
||||
'artworks', 'artwork' => 'Artwork',
|
||||
'wallpapers', 'wallpaper' => 'Wallpaper',
|
||||
'skins', 'skin' => 'Skin',
|
||||
'photography', 'photo', 'photos' => 'Photography',
|
||||
'other' => 'Other',
|
||||
default => $rawContentType !== ''
|
||||
?
|
||||
Illuminate\Support\Str::title(str_replace(['-', '_'], ' ', $rawContentType))
|
||||
: '',
|
||||
};
|
||||
|
||||
$category = trim((string) ($art->category_name ?? $art->category ?? ''));
|
||||
|
||||
$avatarUserId = $art->user->id ?? $art->user_id ?? null;
|
||||
@@ -64,7 +87,8 @@
|
||||
$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');
|
||||
$thumbUrl = is_object($art) && method_exists($art, 'thumbUrl') ? $art->thumbUrl('md') : null;
|
||||
$imgSrc = (string) ($art->thumb ?? $art->thumbnail_url ?? $thumbUrl ?? '/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);
|
||||
@@ -110,14 +134,14 @@
|
||||
$authorUrl = $username !== '' ? '/@' . strtolower($username) : null;
|
||||
|
||||
$metaParts = [];
|
||||
if ($resolution !== '') {
|
||||
$metaParts[] = $resolution;
|
||||
if ($contentType !== '') {
|
||||
$metaParts[] = $contentType;
|
||||
}
|
||||
if ($category !== '') {
|
||||
$metaParts[] = $category;
|
||||
}
|
||||
if ($license !== '') {
|
||||
$metaParts[] = $license;
|
||||
if ($resolution !== '') {
|
||||
$metaParts[] = $resolution;
|
||||
}
|
||||
@endphp
|
||||
|
||||
@@ -160,21 +184,18 @@
|
||||
|
||||
<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>
|
||||
<div class="mt-1 flex items-start justify-between gap-3 text-xs text-white/80">
|
||||
<span class="flex min-w-0 items-start gap-3">
|
||||
<img src="{{ $avatarUrl }}" alt="Avatar of {{ e($author) }}" class="w-9 h-9 rounded-full object-cover">
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate">{{ $author }}@if($username !== '') <span class="text-white/60">{{ '@' . $username }}</span>@endif</span>
|
||||
@if(!empty($metaParts))
|
||||
<span class="mt-0.5 block truncate text-[11px] text-white/70">{{ implode(' • ', $metaParts) }}</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>
|
||||
|
||||
|
||||
@@ -7,15 +7,12 @@
|
||||
@if($artworks->isEmpty())
|
||||
<p class="text-sm text-gray-500">You have not uploaded any artworks yet.</p>
|
||||
@else
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
@foreach($artworks as $art)
|
||||
<div class="bg-panel p-3 rounded">
|
||||
<a href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">
|
||||
<img src="{{ $art->thumbUrl('md') ?? 'https://files.skinbase.org/default/missing_md.webp' }}" alt="{{ $art->title }}" class="w-full h-36 object-cover rounded" />
|
||||
</a>
|
||||
<div class="mt-2 text-sm">
|
||||
<a class="font-medium" href="/art/{{ $art->id }}/{{ Illuminate\Support\Str::slug($art->title ?? 'art') }}">{{ $art->title }}</a>
|
||||
<div class="text-xs text-soft mt-1">Published: {{ optional($art->published_at)->format('Y-m-d') }}</div>
|
||||
<div class="space-y-3">
|
||||
<x-artwork-card :art="$art" />
|
||||
<div class="rounded-xl border border-white/5 bg-white/[0.03] px-3 py-2">
|
||||
<div class="text-xs text-soft">Published: {{ optional($art->published_at)->format('Y-m-d') }}</div>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<a href="{{ route('dashboard.artworks.edit', ['id' => $art->id]) }}" class="text-xs px-2 py-1 bg-black/10 rounded">Edit</a>
|
||||
<form method="POST" action="{{ route('dashboard.artworks.destroy', ['id' => $art->id]) }}" onsubmit="return confirm('Really delete this artwork?');">
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
@extends('layouts.nova')
|
||||
|
||||
@section('content')
|
||||
@php
|
||||
$headerBreadcrumbs = collect([
|
||||
(object) ['name' => $page_title ?? 'Most Downloaded Today', 'url' => route('legacy.today_downloads')],
|
||||
]);
|
||||
@endphp
|
||||
|
||||
{{-- ── Hero header ── --}}
|
||||
<div class="px-6 pt-10 pb-6 md:px-10">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Downloads</p>
|
||||
<h1 class="text-3xl font-bold text-white leading-tight">Most Downloaded Today</h1>
|
||||
<p class="mt-1 text-sm text-white/50">
|
||||
Artworks downloaded the most on <time datetime="{{ now()->toDateString() }}">{{ now()->format('d F Y') }}</time>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Live today
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@section('content')
|
||||
<x-nova-page-header
|
||||
section="Downloads"
|
||||
:title="$page_title ?? 'Most Downloaded Today'"
|
||||
icon="fa-download"
|
||||
:breadcrumbs="$headerBreadcrumbs"
|
||||
:description="'Artworks downloaded the most on <time datetime="' . e(now()->toDateString()) . '">' . e(now()->format('d F Y')) . '</time>.'"
|
||||
headerClass="pb-6"
|
||||
>
|
||||
<x-slot name="actions">
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-medium bg-emerald-500/10 text-emerald-300 ring-1 ring-emerald-500/25">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Live today
|
||||
</span>
|
||||
</x-slot>
|
||||
</x-nova-page-header>
|
||||
|
||||
{{-- ── Artwork grid ── --}}
|
||||
<div class="px-6 pb-16 md:px-10">
|
||||
<div class="px-6 pt-8 pb-16 md:px-10">
|
||||
@if ($artworks && $artworks->isNotEmpty())
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5 gap-4 md:gap-5">
|
||||
@foreach ($artworks as $art)
|
||||
|
||||
@@ -14,15 +14,10 @@
|
||||
<div class="panel panel-default effect2">
|
||||
<div class="panel-heading"><strong>User Gallery</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="gallery-grid">
|
||||
<div class="gallery-grid grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
@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>
|
||||
<x-artwork-card :art="$art" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,5 @@
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
||||
@endpush
|
||||
|
||||
|
||||
Reference in New Issue
Block a user