fixed gallery

This commit is contained in:
2026-02-22 17:09:34 +01:00
parent 48e2055b6a
commit 5c97488e80
33 changed files with 2062 additions and 550 deletions

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class ArtworkInteractionController extends Controller
{
public function favorite(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
request: $request,
table: 'user_favorites',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now()],
requiredTable: 'user_favorites'
);
$this->syncArtworkStats($artworkId);
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function like(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
request: $request,
table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_likes'
);
$this->syncArtworkStats($artworkId);
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function report(Request $request, int $artworkId): JsonResponse
{
if (! Schema::hasTable('artwork_reports')) {
return response()->json(['message' => 'Reporting unavailable'], 422);
}
$data = $request->validate([
'reason' => ['nullable', 'string', 'max:1000'],
]);
DB::table('artwork_reports')->updateOrInsert(
[
'artwork_id' => $artworkId,
'reporter_user_id' => (int) $request->user()->id,
],
[
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
'reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]
);
return response()->json(['ok' => true, 'reported' => true]);
}
public function follow(Request $request, int $userId): JsonResponse
{
if (! Schema::hasTable('friends_list')) {
return response()->json(['message' => 'Follow unavailable'], 422);
}
$actorId = (int) $request->user()->id;
if ($actorId === $userId) {
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$state = $request->boolean('state', true);
$query = DB::table('friends_list')
->where('user_id', $actorId)
->where('friend_id', $userId);
if ($state) {
if (! $query->exists()) {
DB::table('friends_list')->insert([
'user_id' => $actorId,
'friend_id' => $userId,
'date_added' => now(),
]);
}
} else {
$query->delete();
}
$followersCount = (int) DB::table('friends_list')
->where('friend_id', $userId)
->count();
return response()->json([
'ok' => true,
'is_following' => $state,
'followers_count' => $followersCount,
]);
}
private function toggleSimple(
Request $request,
string $table,
array $keyColumns,
array $keyValues,
array $insertPayload,
string $requiredTable
): void {
if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable');
}
$state = $request->boolean('state', true);
$query = DB::table($table);
foreach ($keyColumns as $column) {
$query->where($column, $keyValues[$column]);
}
if ($state) {
if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
}
} else {
$query->delete();
}
}
private function syncArtworkStats(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
$favorites = Schema::hasTable('user_favorites')
? (int) DB::table('user_favorites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
'updated_at' => now(),
]
);
}
private function statusPayload(int $viewerId, int $artworkId): array
{
$isFavorited = Schema::hasTable('user_favorites')
? DB::table('user_favorites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isLiked = Schema::hasTable('artwork_likes')
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$favorites = Schema::hasTable('user_favorites')
? (int) DB::table('user_favorites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
return [
'ok' => true,
'is_favorited' => $isFavorited,
'is_liked' => $isLiked,
'stats' => [
'favorites' => $favorites,
'likes' => $likes,
],
];
}
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Services\LegacyService; use App\Services\LegacyService;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ArtController extends Controller class ArtController extends Controller
{ {
@@ -18,29 +19,23 @@ class ArtController extends Controller
public function show(Request $request, $id, $slug = null) public function show(Request $request, $id, $slug = null)
{ {
// canonicalize to new artwork route when possible // Keep this controller for legacy comment posting and fallback only.
// Canonical artwork page rendering is handled by ArtworkPageController.
try { try {
$art = \App\Models\Artwork::find((int) $id); $art = \App\Models\Artwork::find((int) $id);
if ($art && !empty($art->slug)) { if ($art && $request->isMethod('get')) {
if ($slug !== $art->slug) { $canonicalSlug = Str::slug((string) ($art->slug ?: $art->title));
// attempt to derive contentType and category for route if ($canonicalSlug === '') {
$category = $art->categories()->with('contentType')->first(); $canonicalSlug = (string) $art->id;
if ($category && $category->contentType) {
$contentTypeSlug = $category->contentType->slug ?? 'other';
$categoryPath = $category->slug ?? $category->category_name ?? 'other';
return redirect(route('artworks.show', [
'contentTypeSlug' => $contentTypeSlug,
'categoryPath' => $categoryPath,
'artwork' => $art->slug,
]), 301);
} elseif (!empty($art->slug)) {
// fallback: redirect to artwork slug only (may be handled by router)
return redirect('/' . $art->slug, 301);
}
} }
return redirect()->route('art.show', [
'id' => (int) $art->id,
'slug' => $canonicalSlug,
], 301);
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// ignore and continue rendering legacy view // keep legacy fallback below
} }
if ($request->isMethod('post') && $request->input('action') === 'store_comment') { if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
if (auth()->check()) { if (auth()->check()) {

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
final class ArtworkPageController extends Controller
{
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse
{
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'tags', 'stats'])
->where('id', $id)
->public()
->published()
->firstOrFail();
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
if ($canonicalSlug === '') {
$canonicalSlug = (string) $artwork->id;
}
if ((string) $slug !== $canonicalSlug) {
return redirect()->route('art.show', [
'id' => $artwork->id,
'slug' => $canonicalSlug,
], 301);
}
$thumbMd = ThumbnailPresenter::present($artwork, 'md');
$thumbLg = ThumbnailPresenter::present($artwork, 'lg');
$thumbXl = ThumbnailPresenter::present($artwork, 'xl');
$thumbSq = ThumbnailPresenter::present($artwork, 'sq');
$artworkData = (new ArtworkResource($artwork))->toArray($request);
$canonical = route('art.show', ['id' => $artwork->id, 'slug' => $canonicalSlug]);
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: 'Artist';
$description = Str::limit(trim(strip_tags((string) ($artwork->description ?? ''))), 160, '…');
$meta = [
'title' => sprintf('%s by %s | Skinbase', (string) $artwork->title, (string) $authorName),
'description' => $description !== '' ? $description : (string) $artwork->title,
'canonical' => $canonical,
'og_image' => $thumbXl['url'] ?? $thumbLg['url'] ?? null,
'og_width' => $thumbXl['width'] ?? $thumbLg['width'] ?? null,
'og_height' => $thumbXl['height'] ?? $thumbLg['height'] ?? null,
];
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
$tagIds = $artwork->tags->pluck('id')->filter()->values();
$related = Artwork::query()
->with(['user', 'categories.contentType'])
->whereKeyNot($artwork->id)
->public()
->published()
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
$query->where('user_id', $artwork->user_id);
if ($categoryIds->isNotEmpty()) {
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
$categoryQuery->whereIn('categories.id', $categoryIds->all());
});
}
if ($tagIds->isNotEmpty()) {
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
$tagQuery->whereIn('tags.id', $tagIds->all());
});
}
})
->latest('published_at')
->limit(12)
->get()
->map(function (Artwork $item): array {
$itemSlug = Str::slug((string) ($item->slug ?: $item->title));
if ($itemSlug === '') {
$itemSlug = (string) $item->id;
}
$md = ThumbnailPresenter::present($item, 'md');
$lg = ThumbnailPresenter::present($item, 'lg');
return [
'id' => (int) $item->id,
'title' => (string) $item->title,
'author' => (string) ($item->user?->name ?: $item->user?->username ?: 'Artist'),
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
'thumb' => $md['url'] ?? null,
'thumb_srcset' => ($md['url'] ?? '') . ' 640w, ' . ($lg['url'] ?? '') . ' 1280w',
];
})
->values()
->all();
return view('artworks.show', [
'artwork' => $artwork,
'artworkData' => $artworkData,
'presentMd' => $thumbMd,
'presentLg' => $thumbLg,
'presentXl' => $thumbXl,
'presentSq' => $thumbSq,
'meta' => $meta,
'relatedItems' => $related,
]);
}
}

View File

@@ -1,8 +1,10 @@
<?php <?php
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ArtworkResource extends JsonResource class ArtworkResource extends JsonResource
{ {
@@ -11,68 +13,105 @@ class ArtworkResource extends JsonResource
*/ */
public function toArray($request): array public function toArray($request): array
{ {
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) { $md = ThumbnailPresenter::present($this->resource, 'md');
return []; $lg = ThumbnailPresenter::present($this->resource, 'lg');
} $xl = ThumbnailPresenter::present($this->resource, 'xl');
$get = function ($key) { $sq = ThumbnailPresenter::present($this->resource, 'sq');
$r = $this->resource;
if ($r instanceof MissingValue || $r === null) {
return null;
}
// Eloquent model: prefer getAttribute to avoid magic proxies
if (method_exists($r, 'getAttribute')) {
return $r->getAttribute($key);
}
if (is_array($r)) {
return $r[$key] ?? null;
}
if (is_object($r)) {
return $r->{$key} ?? null;
}
return null;
};
$hash = (string) ($get('hash') ?? '');
$fileExt = (string) ($get('file_ext') ?? '');
$filesBase = rtrim((string) config('cdn.files_url', ''), '/');
$buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string { $canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
$normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue)); if ($canonicalSlug === '') {
$normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue)); $canonicalSlug = (string) $this->id;
if ($normalizedHash === '' || $normalizedExt === '') return null; }
$h1 = substr($normalizedHash, 0, 2);
$h2 = substr($normalizedHash, 2, 2);
if ($h1 === '' || $h2 === '' || $filesBase === '') return null;
return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt); $followerCount = (int) ($this->user?->profile?->followers_count ?? 0);
}; if (($followerCount <= 0) && Schema::hasTable('friends_list') && !empty($this->user?->id)) {
$followerCount = (int) DB::table('friends_list')
->where('friend_id', (int) $this->user->id)
->count();
}
$viewerId = (int) optional($request->user())->id;
$isLiked = false;
$isFavorited = false;
$isFollowing = false;
if ($viewerId > 0) {
if (Schema::hasTable('artwork_likes')) {
$isLiked = DB::table('artwork_likes')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
if (Schema::hasTable('user_favorites')) {
$isFavorited = DB::table('user_favorites')
->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id)
->exists();
}
if (Schema::hasTable('friends_list') && !empty($this->user?->id)) {
$isFollowing = DB::table('friends_list')
->where('user_id', $viewerId)
->where('friend_id', (int) $this->user->id)
->exists();
}
}
return [ return [
'slug' => $get('slug'), 'id' => (int) $this->id,
'title' => $get('title'), 'slug' => (string) $this->slug,
'description' => $get('description'), 'title' => (string) $this->title,
'width' => $get('width'), 'description' => (string) ($this->description ?? ''),
'height' => $get('height'), 'dimensions' => [
'width' => (int) ($this->width ?? 0),
// File URLs are derived from hash/ext (no DB path dependency) 'height' => (int) ($this->height ?? 0),
],
'published_at' => optional($this->published_at)->toIsoString(),
'canonical_url' => route('art.show', ['id' => (int) $this->id, 'slug' => $canonicalSlug]),
'thumbs' => [
'md' => $md,
'lg' => $lg,
'xl' => $xl,
'sq' => $sq,
],
'file' => [ 'file' => [
'name' => $get('file_name') ?? null, 'url' => $lg['url'] ?? null,
'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)), 'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource),
'size' => $get('file_size') ?? null, 'mime_type' => 'image/webp',
'mime_type' => $get('mime_type') ?? null,
], ],
'user' => [
'categories' => $this->whenLoaded('categories', function () { 'id' => (int) ($this->user?->id ?? 0),
return $this->categories->map(fn($c) => [ 'name' => (string) ($this->user?->name ?? ''),
'slug' => $c->slug ?? null, 'username' => (string) ($this->user?->username ?? ''),
'name' => $c->name ?? null, 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
])->values(); 'avatar_url' => $this->user?->profile?->avatar_url,
}), 'followers_count' => $followerCount,
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
'urls' => [
'canonical' => $get('canonical_url') ?? null,
], ],
'viewer' => [
'is_liked' => $isLiked,
'is_favorited' => $isFavorited,
'is_following_author' => $isFollowing,
'is_authenticated' => $viewerId > 0,
'id' => $viewerId > 0 ? $viewerId : null,
],
'stats' => [
'views' => (int) ($this->stats?->views ?? 0),
'downloads' => (int) ($this->stats?->downloads ?? 0),
'favorites' => (int) ($this->stats?->favorites ?? 0),
'likes' => (int) ($this->stats?->rating_count ?? 0),
],
'categories' => $this->categories->map(fn ($category) => [
'id' => (int) $category->id,
'slug' => (string) $category->slug,
'name' => (string) $category->name,
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
])->values(),
'tags' => $this->tags->map(fn ($tag) => [
'id' => (int) $tag->id,
'slug' => (string) $tag->slug,
'name' => (string) $tag->name,
])->values(),
]; ];
} }
} }

View File

@@ -54,12 +54,7 @@ class AvatarService
{ {
$this->assertImageManagerAvailable(); $this->assertImageManagerAvailable();
$this->assertStorageIsAllowed(); $this->assertStorageIsAllowed();
$this->assertSecureImageUpload($file); $binary = $this->assertSecureImageUpload($file);
$binary = file_get_contents($file->getRealPath());
if ($binary === false || $binary === '') {
throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
}
return $this->storeFromBinary($userId, $binary); return $this->storeFromBinary($userId, $binary);
} }
@@ -230,8 +225,12 @@ class AvatarService
} }
} }
private function assertSecureImageUpload(UploadedFile $file): void private function assertSecureImageUpload(UploadedFile $file): string
{ {
if (! $file->isValid()) {
throw new RuntimeException('Avatar upload is not valid.');
}
$extension = strtolower((string) $file->getClientOriginalExtension()); $extension = strtolower((string) $file->getClientOriginalExtension());
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) { if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
throw new RuntimeException('Unsupported avatar file extension.'); throw new RuntimeException('Unsupported avatar file extension.');
@@ -242,7 +241,12 @@ class AvatarService
throw new RuntimeException('Unsupported avatar MIME type.'); throw new RuntimeException('Unsupported avatar MIME type.');
} }
$binary = file_get_contents($file->getRealPath()); $uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || !is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded avatar path.');
}
$binary = file_get_contents($uploadPath);
if ($binary === false || $binary === '') { if ($binary === false || $binary === '') {
throw new RuntimeException('Unable to read uploaded avatar data.'); throw new RuntimeException('Unable to read uploaded avatar data.');
} }
@@ -257,5 +261,7 @@ class AvatarService
if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) { if (!is_array($dimensions) || ($dimensions[0] ?? 0) < 1 || ($dimensions[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded avatar is not a valid image.'); throw new RuntimeException('Uploaded avatar is not a valid image.');
} }
return $binary;
} }
} }

View File

@@ -1,28 +1,75 @@
<?php <?php
namespace App\Services; namespace App\Services;
use App\Services\ThumbnailService; use App\Models\Artwork;
class ThumbnailPresenter class ThumbnailPresenter
{ {
private const MISSING_BASE = 'https://files.skinbase.org/default';
private const WIDTHS = [
'xs' => 160,
'sm' => 320,
'thumb' => 320,
'md' => 640,
'lg' => 1280,
'xl' => 1920,
'sq' => 400,
];
private const HEIGHTS = [
'xs' => 90,
'sm' => 180,
'thumb' => 180,
'md' => 360,
'lg' => 720,
'xl' => 1080,
'sq' => 400,
];
/** /**
* Present thumbnail data for an item which may be a model or an array. * Present thumbnail data for an item which may be a model or an array.
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null] * Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
*/ */
public static function present($item, string $size = 'md'): array public static function present($item, string $size = 'md'): array
{ {
$uext = 'jpg'; $size = self::normalizeSize($size);
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
$id = null; $id = null;
$title = ''; $title = '';
if ($item instanceof Artwork) {
$id = $item->id;
$title = (string) $item->title;
$url = self::resolveArtworkUrl($item, $size);
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => self::buildSrcsetFromArtwork($item),
];
}
$uext = 'webp';
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
if ($isEloquent) { if ($isEloquent) {
$id = $item->id ?? null; $id = $item->id ?? null;
$title = $item->name ?? ''; $title = $item->title ?? ($item->name ?? '');
$url = $item->thumb_url ?? $item->thumb ?? ''; $url = $item->thumb_url ?? $item->thumb ?? '';
$srcset = $item->thumb_srcset ?? null; $srcset = $item->thumb_srcset ?? null;
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset]; if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => $srcset,
];
} }
// If it's an object but not an Eloquent model (e.g. stdClass row), cast to array // If it's an object but not an Eloquent model (e.g. stdClass row), cast to array
@@ -35,15 +82,87 @@ class ThumbnailPresenter
// If array contains direct hash/thumb_ext, use CDN fromHash // If array contains direct hash/thumb_ext, use CDN fromHash
$hash = $item['hash'] ?? null; $hash = $item['hash'] ?? null;
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext); $thumbExt = 'webp';
if (!empty($hash) && !empty($thumbExt)) { if (!empty($hash) && !empty($thumbExt)) {
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6); $url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
$srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt); $srcset = ThumbnailService::srcsetFromHash($hash, $thumbExt);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => $srcset]; if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => $srcset,
];
} }
// Fallback: ask ThumbnailService to resolve by id or file path // Fallback: ask ThumbnailService to resolve by id or file path
$url = ThumbnailService::url(null, $id, $uext, 6); $url = ThumbnailService::url(null, $id, $uext, 6);
return ['id' => $id, 'title' => $title, 'url' => $url, 'srcset' => null]; if (empty($url)) {
$url = self::missingUrl($size);
}
return [
'id' => $id,
'title' => $title,
'url' => $url,
'width' => self::WIDTHS[$size] ?? null,
'height' => self::HEIGHTS[$size] ?? null,
'srcset' => null,
];
}
public static function srcsetForArtwork(Artwork $artwork): string
{
return self::buildSrcsetFromArtwork($artwork);
}
private static function resolveArtworkUrl(Artwork $artwork, string $size): string
{
$hash = $artwork->hash ?? null;
if (!empty($hash)) {
$url = ThumbnailService::fromHash((string) $hash, 'webp', $size);
if (!empty($url)) {
return $url;
}
}
$filePath = $artwork->file_path ?? $artwork->file_name ?? null;
if (!empty($filePath)) {
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
$path = ltrim((string) $filePath, '/');
$pathWithoutExt = preg_replace('/\.[^.]+$/', '', $path);
return sprintf('%s/%s/%s.webp', $cdn, $size, $pathWithoutExt);
}
return self::missingUrl($size);
}
private static function buildSrcsetFromArtwork(Artwork $artwork): string
{
$md = self::resolveArtworkUrl($artwork, 'md');
$lg = self::resolveArtworkUrl($artwork, 'lg');
$xl = self::resolveArtworkUrl($artwork, 'xl');
return implode(', ', [
$md . ' 640w',
$lg . ' 1280w',
$xl . ' 1920w',
]);
}
private static function normalizeSize(string $size): string
{
$size = strtolower(trim($size));
return array_key_exists($size, self::WIDTHS) ? $size : 'md';
}
private static function missingUrl(string $size): string
{
return sprintf('%s/missing_%s.webp', self::MISSING_BASE, $size);
} }
} }

View File

@@ -1,20 +1,38 @@
<?php <?php
declare(strict_types=1);
namespace App\Services; namespace App\Services;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
class ThumbnailService class ThumbnailService
{ {
// Use the thumbnails CDN host (HTTPS) /**
protected const CDN_HOST = 'https://files.skinbase.org'; * CDN host is read from config/cdn.php FILES_CDN_URL env.
* Hardcoding the domain is forbidden per upload-agent spec §3A.
*/
protected static function cdnHost(): string
{
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
}
protected const VALID_SIZES = ['sm','md','lg','xl']; /**
* Canonical size keys (upload-agent spec §8): thumb · sq · md · lg · xl
* 'sm' is kept as a backwards-compatible alias for 'thumb'.
*/
protected const VALID_SIZES = ['thumb', 'sq', 'sm', 'md', 'lg', 'xl'];
/** Size aliases: legacy 'sm' maps to the 'thumb' CDN directory. */
protected const SIZE_ALIAS = ['sm' => 'thumb'];
protected const THUMB_SIZES = [ protected const THUMB_SIZES = [
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'], 'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'], 'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true],
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'], 'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'], 'md' => ['height' => 1024, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'],
]; ];
/** /**
@@ -26,7 +44,7 @@ class ThumbnailService
{ {
// If $filePath seems to be a content hash and $ext is provided, build directly // If $filePath seems to be a content hash and $ext is provided, build directly
if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) { if (!empty($filePath) && !empty($ext) && preg_match('/^[0-9a-f]{16,128}$/i', $filePath)) {
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md'); $sizeKey = is_string($size) ? $size : (($size === 4) ? 'thumb' : 'md');
return self::fromHash($filePath, $ext, $sizeKey) ?: ''; return self::fromHash($filePath, $ext, $sizeKey) ?: '';
} }
@@ -39,7 +57,7 @@ class ThumbnailService
if ($art) { if ($art) {
$hash = $art->hash ?? null; $hash = $art->hash ?? null;
$extToUse = $ext ?? ($art->thumb_ext ?? null); $extToUse = $ext ?? ($art->thumb_ext ?? null);
$sizeKey = is_string($size) ? $size : (($size === 4) ? 'sm' : 'md'); $sizeKey = is_string($size) ? $size : (($size === 4) ? 'thumb' : 'md');
if (!empty($hash) && !empty($extToUse)) { if (!empty($hash) && !empty($extToUse)) {
return self::fromHash($hash, $extToUse, $sizeKey) ?: ''; return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
} }
@@ -68,11 +86,14 @@ class ThumbnailService
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
{ {
if (empty($hash) || empty($ext)) return null; if (empty($hash) || empty($ext)) return null;
// Resolve alias (sm → thumb) then validate
$sizeKey = self::SIZE_ALIAS[$sizeKey] ?? $sizeKey;
$sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md'; $sizeKey = in_array($sizeKey, self::VALID_SIZES) ? $sizeKey : 'md';
$dir = self::THUMB_SIZES[$sizeKey]['dir'] ?? $sizeKey;
$h = $hash; $h = $hash;
$h1 = substr($h, 0, 2); $h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2); $h2 = substr($h, 2, 2);
return sprintf('%s/%s/%s/%s/%s.%s', rtrim(self::CDN_HOST, '/'), $sizeKey, $h1, $h2, $h, $ext); return sprintf('%s/%s/%s/%s/%s.%s', self::cdnHost(), $dir, $h1, $h2, $h, $ext);
} }
/** /**
@@ -80,9 +101,9 @@ class ThumbnailService
*/ */
public static function srcsetFromHash(?string $hash, ?string $ext): ?string public static function srcsetFromHash(?string $hash, ?string $ext): ?string
{ {
$a = self::fromHash($hash, $ext, 'sm'); $a = self::fromHash($hash, $ext, 'thumb'); // 320px
$b = self::fromHash($hash, $ext, 'md'); $b = self::fromHash($hash, $ext, 'md'); // 1024px
if (!$a || !$b) return null; if (!$a || !$b) return null;
return $a . ' 320w, ' . $b . ' 600w'; return $a . ' 320w, ' . $b . ' 1024w';
} }
} }

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_likes', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'artwork_id'], 'artwork_likes_unique_user_artwork');
$table->index('artwork_id');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_likes');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('artwork_reports', function (Blueprint $table): void {
$table->id();
$table->foreignId('artwork_id')->constrained('artworks')->cascadeOnDelete();
$table->foreignId('reporter_user_id')->constrained('users')->cascadeOnDelete();
$table->text('reason')->nullable();
$table->timestamp('reported_at')->nullable();
$table->timestamps();
$table->unique(['artwork_id', 'reporter_user_id'], 'artwork_reports_unique_reporter_per_artwork');
$table->index('reported_at');
});
}
public function down(): void
{
Schema::dropIfExists('artwork_reports');
}
};

View File

@@ -14,8 +14,9 @@
} }
})(); })();
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220; var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 200;
var LOAD_TRIGGER_MARGIN = '900px'; var LOAD_TRIGGER_MARGIN = '900px';
var VIRTUAL_OBSERVER_MARGIN = '800px';
function toArray(list) { function toArray(list) {
return Array.prototype.slice.call(list || []); return Array.prototype.slice.call(list || []);
@@ -28,6 +29,14 @@
return next ? next.getAttribute('href') : null; return next ? next.getAttribute('href') : null;
} }
function buildCursorUrl(endpoint, cursor, limit) {
if (!endpoint) return null;
var url = new URL(endpoint, window.location.href);
if (cursor) url.searchParams.set('cursor', cursor);
if (limit) url.searchParams.set('limit', limit);
return url.toString();
}
function setSkeleton(root, active, count) { function setSkeleton(root, active, count) {
var box = root.querySelector('[data-gallery-skeleton]'); var box = root.querySelector('[data-gallery-skeleton]');
if (!box) return; if (!box) return;
@@ -81,37 +90,37 @@
}); });
} }
function applyVirtualizationHints(root) { function activateBlurPreviews(root) {
var grid = root.querySelector('[data-gallery-grid]'); var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
if (!grid) return; imgs.forEach(function (img) {
var cards = toArray(grid.querySelectorAll('.nova-card')); if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) { img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
cards.forEach(function (card) { img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
}); });
return;
} }
var viewportTop = window.scrollY; // Create an IntersectionObserver that applies content-visibility hints
var viewportBottom = viewportTop + window.innerHeight; // to cards that leave the viewport. Using an observer avoids calling
// getBoundingClientRect() in a scroll handler (which forces layout).
cards.forEach(function (card) { // entry.boundingClientRect gives us the last rendered height without
var rect = card.getBoundingClientRect(); // triggering a synchronous layout recalculation.
var top = rect.top + viewportTop; function makeVirtualObserver() {
var bottom = rect.bottom + viewportTop; if (!('IntersectionObserver' in window)) return null;
var farAbove = bottom < viewportTop - 1400; return new IntersectionObserver(function (entries) {
var farBelow = top > viewportBottom + 2600; entries.forEach(function (entry) {
var card = entry.target;
if (farAbove || farBelow) { if (entry.isIntersecting) {
var h = Math.max(160, rect.height || 220); card.style.contentVisibility = '';
card.style.contentVisibility = 'auto'; card.style.containIntrinsicSize = '';
card.style.containIntrinsicSize = Math.round(h) + 'px';
} else { } else {
card.style.contentVisibility = ''; // Capture the last-known rendered height before hiding.
card.style.containIntrinsicSize = ''; // 'auto <h>px' keeps browser-managed width while reserving fixed height.
var h = Math.max(160, Math.round(entry.boundingClientRect.height) || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = 'auto ' + h + 'px';
} }
}); });
}, { root: null, rootMargin: VIRTUAL_OBSERVER_MARGIN, threshold: 0 });
} }
function extractAndAppendCards(root, html) { function extractAndAppendCards(root, html) {
@@ -145,13 +154,40 @@
if (!grid) return; if (!grid) return;
root.classList.add('is-enhanced'); root.classList.add('is-enhanced');
activateBlurPreviews(root);
var state = { var state = {
loading: false, loading: false,
nextUrl: queryNextPageUrl(root), nextUrl: queryNextPageUrl(root),
cursorEndpoint: (root.dataset && root.dataset.galleryCursorEndpoint) || null,
cursor: (root.dataset && root.dataset.galleryCursor) || null,
limit: (root.dataset && parseInt(root.dataset.galleryLimit, 10)) || 40,
done: false done: false
}; };
// virtualObserver is created lazily the first time card count exceeds
// MAX_DOM_CARDS_FOR_VIRTUAL_HINT. Once active it watches every card.
var virtualObserver = null;
// Call after appending new cards. newCards is the array of freshly added
// elements (pass [] on the initial render). When the threshold is first
// crossed all existing cards are swept; thereafter only newCards are added.
function checkVirtualization(newCards) {
var allCards = toArray(grid.querySelectorAll('.nova-card'));
if (allCards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) return;
if (!virtualObserver) {
// First time crossing the threshold — create observer and observe all.
virtualObserver = makeVirtualObserver();
if (!virtualObserver) return;
allCards.forEach(function (card) { virtualObserver.observe(card); });
return;
}
// Observer already running — just wire in newly appended cards.
newCards.forEach(function (card) { virtualObserver.observe(card); });
}
function relayout() { function relayout() {
// Apply masonry synchronously first — the card already has inline aspect-ratio // Apply masonry synchronously first — the card already has inline aspect-ratio
// set from image dimensions, so getBoundingClientRect() returns the correct // set from image dimensions, so getBoundingClientRect() returns the correct
@@ -167,21 +203,18 @@
if (!GRID_V2_ENABLED) { if (!GRID_V2_ENABLED) {
applyMasonry(root); applyMasonry(root);
} }
applyVirtualizationHints(root); // Virtualization check runs after the initial render too, in case the
}); // page was loaded mid-scroll with many pre-rendered cards.
} checkVirtualization([]);
var rafId = null;
function onScrollOrResize() {
if (rafId) return;
rafId = window.requestAnimationFrame(function () {
rafId = null;
applyVirtualizationHints(root);
}); });
} }
async function loadNextPage() { async function loadNextPage() {
if (state.loading || state.done || !state.nextUrl) return; if (state.loading || state.done) return;
var fetchUrl = (state.cursorEndpoint && state.cursor)
? buildCursorUrl(state.cursorEndpoint, state.cursor, state.limit)
: state.nextUrl;
if (!fetchUrl) return;
state.loading = true; state.loading = true;
var sampleCards = toArray(grid.querySelectorAll('.nova-card')); var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
@@ -189,7 +222,7 @@
setSkeleton(root, true, skeletonCount); setSkeleton(root, true, skeletonCount);
try { try {
var response = await window.fetch(state.nextUrl, { var response = await window.fetch(fetchUrl, {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
@@ -201,6 +234,7 @@
if (!state.nextUrl || result.appended === 0) { if (!state.nextUrl || result.appended === 0) {
state.done = true; state.done = true;
} }
activateBlurPreviews(root);
// Animate appended cards // Animate appended cards
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended); var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
@@ -212,6 +246,7 @@
}); });
relayout(); relayout();
checkVirtualization(appendedCards);
// After new cards appended, move the trigger to remain one-row-before-last. // After new cards appended, move the trigger to remain one-row-before-last.
placeTrigger(); placeTrigger();
} catch (e) { } catch (e) {
@@ -275,10 +310,8 @@
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
relayout(); relayout();
onScrollOrResize();
placeTrigger(); placeTrigger();
}, { passive: true }); }, { passive: true });
window.addEventListener('scroll', onScrollOrResize, { passive: true });
relayout(); relayout();
placeTrigger(); placeTrigger();

View File

@@ -14,8 +14,9 @@
} }
})(); })();
var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 220; var MAX_DOM_CARDS_FOR_VIRTUAL_HINT = 200;
var LOAD_TRIGGER_MARGIN = '900px'; var LOAD_TRIGGER_MARGIN = '900px';
var VIRTUAL_OBSERVER_MARGIN = '800px';
function toArray(list) { function toArray(list) {
return Array.prototype.slice.call(list || []); return Array.prototype.slice.call(list || []);
@@ -28,6 +29,14 @@
return next ? next.getAttribute('href') : null; return next ? next.getAttribute('href') : null;
} }
function buildCursorUrl(endpoint, cursor, limit) {
if (!endpoint) return null;
var url = new URL(endpoint, window.location.href);
if (cursor) url.searchParams.set('cursor', cursor);
if (limit) url.searchParams.set('limit', limit);
return url.toString();
}
function setSkeleton(root, active, count) { function setSkeleton(root, active, count) {
var box = root.querySelector('[data-gallery-skeleton]'); var box = root.querySelector('[data-gallery-skeleton]');
if (!box) return; if (!box) return;
@@ -81,37 +90,37 @@
}); });
} }
function applyVirtualizationHints(root) { function activateBlurPreviews(root) {
var grid = root.querySelector('[data-gallery-grid]'); var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
if (!grid) return; imgs.forEach(function (img) {
var cards = toArray(grid.querySelectorAll('.nova-card')); if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) { img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
cards.forEach(function (card) { img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
card.style.contentVisibility = '';
card.style.containIntrinsicSize = '';
}); });
return;
} }
var viewportTop = window.scrollY; // Create an IntersectionObserver that applies content-visibility hints
var viewportBottom = viewportTop + window.innerHeight; // to cards that leave the viewport. Using an observer avoids calling
// getBoundingClientRect() in a scroll handler (which forces layout).
cards.forEach(function (card) { // entry.boundingClientRect gives us the last rendered height without
var rect = card.getBoundingClientRect(); // triggering a synchronous layout recalculation.
var top = rect.top + viewportTop; function makeVirtualObserver() {
var bottom = rect.bottom + viewportTop; if (!('IntersectionObserver' in window)) return null;
var farAbove = bottom < viewportTop - 1400; return new IntersectionObserver(function (entries) {
var farBelow = top > viewportBottom + 2600; entries.forEach(function (entry) {
var card = entry.target;
if (farAbove || farBelow) { if (entry.isIntersecting) {
var h = Math.max(160, rect.height || 220); card.style.contentVisibility = '';
card.style.contentVisibility = 'auto'; card.style.containIntrinsicSize = '';
card.style.containIntrinsicSize = Math.round(h) + 'px';
} else { } else {
card.style.contentVisibility = ''; // Capture the last-known rendered height before hiding.
card.style.containIntrinsicSize = ''; // 'auto <h>px' keeps browser-managed width while reserving fixed height.
var h = Math.max(160, Math.round(entry.boundingClientRect.height) || 220);
card.style.contentVisibility = 'auto';
card.style.containIntrinsicSize = 'auto ' + h + 'px';
} }
}); });
}, { root: null, rootMargin: VIRTUAL_OBSERVER_MARGIN, threshold: 0 });
} }
function extractAndAppendCards(root, html) { function extractAndAppendCards(root, html) {
@@ -145,13 +154,40 @@
if (!grid) return; if (!grid) return;
root.classList.add('is-enhanced'); root.classList.add('is-enhanced');
activateBlurPreviews(root);
var state = { var state = {
loading: false, loading: false,
nextUrl: queryNextPageUrl(root), nextUrl: queryNextPageUrl(root),
cursorEndpoint: (root.dataset && root.dataset.galleryCursorEndpoint) || null,
cursor: (root.dataset && root.dataset.galleryCursor) || null,
limit: (root.dataset && parseInt(root.dataset.galleryLimit, 10)) || 40,
done: false done: false
}; };
// virtualObserver is created lazily the first time card count exceeds
// MAX_DOM_CARDS_FOR_VIRTUAL_HINT. Once active it watches every card.
var virtualObserver = null;
// Call after appending new cards. newCards is the array of freshly added
// elements (pass [] on the initial render). When the threshold is first
// crossed all existing cards are swept; thereafter only newCards are added.
function checkVirtualization(newCards) {
var allCards = toArray(grid.querySelectorAll('.nova-card'));
if (allCards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) return;
if (!virtualObserver) {
// First time crossing the threshold — create observer and observe all.
virtualObserver = makeVirtualObserver();
if (!virtualObserver) return;
allCards.forEach(function (card) { virtualObserver.observe(card); });
return;
}
// Observer already running — just wire in newly appended cards.
newCards.forEach(function (card) { virtualObserver.observe(card); });
}
function relayout() { function relayout() {
// Apply masonry synchronously first — the card already has inline aspect-ratio // Apply masonry synchronously first — the card already has inline aspect-ratio
// set from image dimensions, so getBoundingClientRect() returns the correct // set from image dimensions, so getBoundingClientRect() returns the correct
@@ -167,21 +203,18 @@
if (!GRID_V2_ENABLED) { if (!GRID_V2_ENABLED) {
applyMasonry(root); applyMasonry(root);
} }
applyVirtualizationHints(root); // Virtualization check runs after the initial render too, in case the
}); // page was loaded mid-scroll with many pre-rendered cards.
} checkVirtualization([]);
var rafId = null;
function onScrollOrResize() {
if (rafId) return;
rafId = window.requestAnimationFrame(function () {
rafId = null;
applyVirtualizationHints(root);
}); });
} }
async function loadNextPage() { async function loadNextPage() {
if (state.loading || state.done || !state.nextUrl) return; if (state.loading || state.done) return;
var fetchUrl = (state.cursorEndpoint && state.cursor)
? buildCursorUrl(state.cursorEndpoint, state.cursor, state.limit)
: state.nextUrl;
if (!fetchUrl) return;
state.loading = true; state.loading = true;
var sampleCards = toArray(grid.querySelectorAll('.nova-card')); var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
@@ -189,7 +222,7 @@
setSkeleton(root, true, skeletonCount); setSkeleton(root, true, skeletonCount);
try { try {
var response = await window.fetch(state.nextUrl, { var response = await window.fetch(fetchUrl, {
credentials: 'same-origin', credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } headers: { 'X-Requested-With': 'XMLHttpRequest' }
}); });
@@ -201,6 +234,7 @@
if (!state.nextUrl || result.appended === 0) { if (!state.nextUrl || result.appended === 0) {
state.done = true; state.done = true;
} }
activateBlurPreviews(root);
// Animate appended cards // Animate appended cards
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended); var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
@@ -212,6 +246,7 @@
}); });
relayout(); relayout();
checkVirtualization(appendedCards);
// After new cards appended, move the trigger to remain one-row-before-last. // After new cards appended, move the trigger to remain one-row-before-last.
placeTrigger(); placeTrigger();
} catch (e) { } catch (e) {
@@ -275,10 +310,8 @@
window.addEventListener('resize', function () { window.addEventListener('resize', function () {
relayout(); relayout();
onScrollOrResize();
placeTrigger(); placeTrigger();
}, { passive: true }); }, { passive: true });
window.addEventListener('scroll', onScrollOrResize, { passive: true });
relayout(); relayout();
placeTrigger(); placeTrigger();

View File

@@ -147,3 +147,18 @@
} }
} }
} }
/* Phase 8 — Virtualization hints (applied via JS IntersectionObserver) */
.nova-card[data-virtual-hidden] {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
pointer-events: none;
user-select: none;
}
.nova-card[data-virtual-visible] {
content-visibility: visible;
contain-intrinsic-size: none;
pointer-events: auto;
user-select: auto;
}

View File

@@ -0,0 +1,69 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import ArtworkHero from '../components/artwork/ArtworkHero'
import ArtworkMeta from '../components/artwork/ArtworkMeta'
import ArtworkActions from '../components/artwork/ArtworkActions'
import ArtworkStats from '../components/artwork/ArtworkStats'
import ArtworkTags from '../components/artwork/ArtworkTags'
import ArtworkAuthor from '../components/artwork/ArtworkAuthor'
import ArtworkRelated from '../components/artwork/ArtworkRelated'
import ArtworkDescription from '../components/artwork/ArtworkDescription'
function ArtworkPage({ artwork, related, presentMd, presentLg, presentXl, presentSq, canonicalUrl }) {
if (!artwork) return null
return (
<main className="mx-auto w-full max-w-screen-xl px-4 pb-24 pt-10 sm:px-6 lg:px-8 lg:pb-12">
<ArtworkHero artwork={artwork} presentMd={presentMd} presentLg={presentLg} presentXl={presentXl} />
<div className="mt-6 lg:hidden">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} mobilePriority />
</div>
<div className="mt-8 grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<ArtworkMeta artwork={artwork} />
<ArtworkAuthor artwork={artwork} presentSq={presentSq} />
<ArtworkStats artwork={artwork} />
<ArtworkTags artwork={artwork} />
<ArtworkDescription artwork={artwork} />
</div>
<aside className="hidden space-y-6 lg:block">
<div className="sticky top-24">
<ArtworkActions artwork={artwork} canonicalUrl={canonicalUrl} />
</div>
</aside>
</div>
<ArtworkRelated related={related} />
</main>
)
}
// Auto-mount if the Blade view provided data attributes
const el = document.getElementById('artwork-page')
if (el) {
const parse = (key, fallback = null) => {
try {
return JSON.parse(el.dataset[key] || 'null') ?? fallback
} catch {
return fallback
}
}
const root = createRoot(el)
root.render(
<ArtworkPage
artwork={parse('artwork')}
related={parse('related', [])}
presentMd={parse('presentMd')}
presentLg={parse('presentLg')}
presentXl={parse('presentXl')}
presentSq={parse('presentSq')}
canonicalUrl={parse('canonical', '')}
/>,
)
}
export default ArtworkPage

View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react'
export default function ArtworkActions({ artwork, canonicalUrl, mobilePriority = false }) {
const [liked, setLiked] = useState(Boolean(artwork?.viewer?.is_liked))
const [favorited, setFavorited] = useState(Boolean(artwork?.viewer?.is_favorited))
const [reporting, setReporting] = useState(false)
const downloadUrl = artwork?.thumbs?.xl?.url || artwork?.thumbs?.lg?.url || artwork?.file?.url || '#'
const shareUrl = canonicalUrl || artwork?.canonical_url || (typeof window !== 'undefined' ? window.location.href : '#')
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const postInteraction = async (url, body) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify(body),
})
if (!response.ok) throw new Error('Request failed')
return response.json()
}
const onToggleLike = async () => {
const nextState = !liked
setLiked(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/like`, { state: nextState })
} catch {
setLiked(!nextState)
}
}
const onToggleFavorite = async () => {
const nextState = !favorited
setFavorited(nextState)
try {
await postInteraction(`/api/artworks/${artwork.id}/favorite`, { state: nextState })
} catch {
setFavorited(!nextState)
}
}
const onShare = async () => {
try {
if (navigator.share) {
await navigator.share({
title: artwork?.title || 'Artwork',
url: shareUrl,
})
return
}
await navigator.clipboard.writeText(shareUrl)
} catch {
// noop
}
}
const onReport = async () => {
if (reporting) return
setReporting(true)
try {
await postInteraction(`/api/artworks/${artwork.id}/report`, {
reason: 'Reported from artwork page',
})
} catch {
// noop
} finally {
setReporting(false)
}
}
return (
<div 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">Actions</h2>
<div className="mt-4 flex flex-col gap-3">
<a
href={downloadUrl}
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
download
>
Download
</a>
<button
type="button"
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
onClick={onToggleLike}
>
{liked ? 'Liked' : 'Like'}
</button>
<button
type="button"
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
onClick={onToggleFavorite}
>
{favorited ? 'Saved' : 'Favorite'}
</button>
<button
type="button"
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
onClick={onShare}
>
Share
</button>
<button
type="button"
className="inline-flex min-h-11 w-full items-center justify-center rounded-lg border border-nova-600 px-4 py-3 text-sm text-white hover:bg-nova-800"
onClick={onReport}
>
{reporting ? 'Reporting…' : 'Report'}
</button>
</div>
{mobilePriority && (
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-50 border-t border-nova-700 bg-panel/95 p-3 backdrop-blur lg:hidden">
<a
href={downloadUrl}
download
className="pointer-events-auto inline-flex min-h-12 w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-deep hover:brightness-110"
>
Download
</a>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,83 @@
import React, { useState } from 'react'
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 user = artwork?.user || {}
const authorName = user.name || user.username || 'Artist'
const profileUrl = user.profile_url || (user.username ? `/@${user.username}` : '#')
const avatar = user.avatar_url || presentSq?.url || AVATAR_FALLBACK
const csrfToken = typeof document !== 'undefined'
? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
: null
const onToggleFollow = async () => {
const nextState = !following
setFollowing(nextState)
try {
const response = await fetch(`/api/users/${user.id}/follow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken || '',
},
credentials: 'same-origin',
body: JSON.stringify({ state: nextState }),
})
if (!response.ok) throw new Error('Follow failed')
const payload = await response.json()
if (typeof payload?.followers_count === 'number') {
setFollowersCount(payload.followers_count)
}
setFollowing(Boolean(payload?.is_following))
} catch {
setFollowing(!nextState)
}
}
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>
<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>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<a
href={profileUrl}
className="inline-flex min-h-11 items-center justify-center rounded-lg border border-nova-600 px-3 py-2 text-sm text-white hover:bg-nova-800"
>
Profile
</a>
<button
type="button"
className={`inline-flex min-h-11 items-center justify-center 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 ? 'Following' : 'Follow'}
</button>
</div>
</section>
)
}

View File

@@ -0,0 +1,75 @@
import React, { useMemo, useState } from 'react'
const COLLAPSE_AT = 560
function renderMarkdownSafe(text) {
const lines = text.split(/\n{2,}/)
return lines.map((line, lineIndex) => {
const parts = []
let rest = line
let key = 0
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g
let match = linkPattern.exec(rest)
let lastIndex = 0
while (match) {
if (match.index > lastIndex) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex, match.index)}</span>)
}
parts.push(
<a
key={`lnk-${lineIndex}-${key++}`}
href={match[2]}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-accent hover:underline"
>
{match[1]}
</a>,
)
lastIndex = match.index + match[0].length
match = linkPattern.exec(rest)
}
if (lastIndex < rest.length) {
parts.push(<span key={`txt-${lineIndex}-${key++}`}>{rest.slice(lastIndex)}</span>)
}
return (
<p key={`p-${lineIndex}`} className="text-base leading-7 text-soft">
{parts}
</p>
)
})
}
export default function ArtworkDescription({ artwork }) {
const [expanded, setExpanded] = useState(false)
const content = (artwork?.description || '').trim()
if (content.length === 0) return null
const collapsed = content.length > COLLAPSE_AT && !expanded
const visibleText = collapsed ? `${content.slice(0, COLLAPSE_AT)}` : content
const rendered = useMemo(() => renderMarkdownSafe(visibleText), [visibleText])
return (
<section className="rounded-xl bg-panel p-5 shadow-lg shadow-deep/30">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Description</h2>
<div className="mt-4 max-w-[720px] space-y-4">{rendered}</div>
{content.length > COLLAPSE_AT && (
<button
type="button"
className="mt-4 text-sm font-medium text-accent hover:underline"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Show less' : 'Show more'}
</button>
)}
</section>
)
}

View File

@@ -0,0 +1,73 @@
import React, { useState } from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
const FALLBACK_LG = 'https://files.skinbase.org/default/missing_lg.webp'
const FALLBACK_XL = 'https://files.skinbase.org/default/missing_xl.webp'
export default function ArtworkHero({ artwork, presentMd, presentLg, presentXl }) {
const [isLoaded, setIsLoaded] = useState(false)
const mdSource = presentMd?.url || artwork?.thumbs?.md?.url || null
const lgSource = presentLg?.url || artwork?.thumbs?.lg?.url || null
const xlSource = presentXl?.url || artwork?.thumbs?.xl?.url || null
const md = mdSource || FALLBACK_MD
const lg = lgSource || FALLBACK_LG
const xl = xlSource || FALLBACK_XL
const hasRealArtworkImage = Boolean(mdSource || lgSource || xlSource)
const blurBackdropSrc = mdSource || lgSource || xlSource || null
const srcSet = `${md} 640w, ${lg} 1280w, ${xl} 1920w`
return (
<figure className="w-full">
<div className="relative mx-auto w-full max-w-[1280px]">
{blurBackdropSrc && (
<div className="pointer-events-none absolute inset-0 -z-10 scale-105 overflow-hidden rounded-2xl">
<img
src={blurBackdropSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover opacity-35 blur-2xl"
loading="lazy"
decoding="async"
/>
</div>
)}
{hasRealArtworkImage && (
<div className="absolute inset-0 -z-10 rounded-2xl bg-gradient-to-b from-nova-700/20 via-nova-900/15 to-deep/40" />
)}
<div className="relative w-full aspect-video rounded-xl overflow-hidden bg-deep shadow-2xl ring-1 ring-nova-600/30">
<img
src={md}
alt={artwork?.title ?? 'Artwork'}
className="absolute inset-0 h-full w-full object-contain"
loading="eager"
decoding="async"
/>
<img
src={lg}
srcSet={srcSet}
sizes="(min-width: 1280px) 1280px, (min-width: 768px) 90vw, 100vw"
alt={artwork?.title ?? 'Artwork'}
className={`absolute inset-0 h-full w-full object-contain transition-opacity duration-500 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
loading="eager"
decoding="async"
onLoad={() => setIsLoaded(true)}
onError={(event) => {
event.currentTarget.src = FALLBACK_LG
}}
/>
</div>
{hasRealArtworkImage && (
<div className="pointer-events-none absolute inset-x-8 -bottom-5 h-10 rounded-full bg-accent/25 blur-2xl" />
)}
</div>
</figure>
)
}

View File

@@ -0,0 +1,30 @@
import React from 'react'
export default function ArtworkMeta({ artwork }) {
const author = artwork?.user?.name || artwork?.user?.username || 'Artist'
const publishedAt = artwork?.published_at
? new Date(artwork.published_at).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
: '—'
const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0
return (
<div className="rounded-xl border border-nova-700 bg-panel p-5">
<h1 className="text-xl font-semibold text-white sm:text-2xl">{artwork?.title}</h1>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm text-soft sm:grid-cols-2">
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
<dt>Author</dt>
<dd className="text-white">{author}</dd>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2">
<dt>Upload date</dt>
<dd className="text-white">{publishedAt}</dd>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2">
<dt>Resolution</dt>
<dd className="text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
</div>
</dl>
</div>
)
}

View File

@@ -0,0 +1,44 @@
import React from 'react'
const FALLBACK_MD = 'https://files.skinbase.org/default/missing_md.webp'
export default function ArtworkRelated({ related }) {
if (!Array.isArray(related) || related.length === 0) return null
return (
<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>
</section>
)
}

View File

@@ -0,0 +1,42 @@
import React from 'react'
function formatCount(value) {
const number = Number(value || 0)
if (number >= 1_000_000) return `${(number / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
if (number >= 1_000) return `${(number / 1_000).toFixed(1).replace(/\.0$/, '')}k`
return `${number}`
}
export default function ArtworkStats({ artwork }) {
const stats = artwork?.stats || {}
const width = artwork?.dimensions?.width || 0
const height = artwork?.dimensions?.height || 0
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">Statistics</h2>
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
<dt className="text-soft">👁 Views</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.views)} views</dd>
</div>
<div className="rounded-lg bg-nova-900/30 px-3 py-2">
<dt className="text-soft"> Downloads</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.downloads)} downloads</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
<dt className="text-soft"> Likes</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.likes)} likes</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:block">
<dt className="text-soft"> Favorites</dt>
<dd className="mt-1 font-medium text-white">{formatCount(stats.favorites)} favorites</dd>
</div>
<div className="hidden rounded-lg bg-nova-900/30 px-3 py-2 sm:col-span-2 sm:block">
<dt className="text-soft">Resolution</dt>
<dd className="mt-1 font-medium text-white">{width > 0 && height > 0 ? `${width} × ${height}` : '—'}</dd>
</div>
</dl>
</section>
)
}

View File

@@ -0,0 +1,58 @@
import React, { useMemo, useState } from 'react'
export default function ArtworkTags({ artwork }) {
const [expanded, setExpanded] = useState(false)
const tags = useMemo(() => {
const primaryCategorySlug = artwork?.categories?.[0]?.slug || 'all'
const categories = (artwork?.categories || []).map((category) => ({
key: `cat-${category.id || category.slug}`,
label: category.name,
href: category.content_type_slug && category.slug
? `/browse/${category.content_type_slug}/${category.slug}`
: `/browse/${category.slug || ''}`,
}))
const artworkTags = (artwork?.tags || []).map((tag) => ({
key: `tag-${tag.id || tag.slug}`,
label: tag.name,
href: `/browse/${primaryCategorySlug}/${tag.slug || ''}`,
}))
return [...categories, ...artworkTags]
}, [artwork])
if (tags.length === 0) return null
const visible = expanded ? tags : tags.slice(0, 12)
return (
<section className="rounded-xl border border-nova-700 bg-panel p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wide text-soft">Tags & Categories</h2>
{tags.length > 12 && (
<button
type="button"
className="text-xs text-accent hover:underline"
onClick={() => setExpanded((value) => !value)}
>
{expanded ? 'Show less' : `Show all (${tags.length})`}
</button>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
{visible.map((tag) => (
<a
key={tag.key}
href={tag.href}
className="inline-flex items-center rounded-full border border-nova-600 bg-nova-900/30 px-3 py-1 text-xs text-white hover:border-accent hover:text-accent"
>
{tag.label}
</a>
))}
</div>
</section>
)
}

View File

@@ -1,266 +1,96 @@
@extends('layouts.nova') @extends('layouts.nova')
@push('head')
<title>{{ $meta['title'] }}</title>
<meta name="description" content="{{ $meta['description'] }}">
<link rel="canonical" href="{{ $meta['canonical'] }}">
<meta property="og:type" content="article">
<meta property="og:site_name" content="Skinbase">
<meta property="og:title" content="{{ $meta['title'] }}">
<meta property="og:description" content="{{ $meta['description'] }}">
<meta property="og:url" content="{{ $meta['canonical'] }}">
@if(!empty($meta['og_image']))
<meta property="og:image" content="{{ $meta['og_image'] }}">
<meta property="og:image:type" content="image/webp">
@if(!empty($meta['og_width']))
<meta property="og:image:width" content="{{ $meta['og_width'] }}">
@endif
@if(!empty($meta['og_height']))
<meta property="og:image:height" content="{{ $meta['og_height'] }}">
@endif
@endif
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $meta['title'] }}">
<meta name="twitter:description" content="{{ $meta['description'] }}">
@if(!empty($meta['og_image']))
<meta name="twitter:image" content="{{ $meta['og_image'] }}">
@endif
@php @php
use App\Banner; $authorName = $artwork->user?->name ?: $artwork->user?->username ?: null;
use App\Models\Category; $keywords = $artwork->tags->pluck('name')->merge($artwork->categories->pluck('name'))->filter()->unique()->implode(', ');
use Illuminate\Pagination\LengthAwarePaginator; $license = $artwork->license_url ?? null;
// Determine a sensible category/context for this artwork so the $imageObject = [
// legacy layout (sidebar + hero) can be rendered similarly to '@context' => 'https://schema.org',
// category listing pages. '@type' => 'ImageObject',
$category = $artwork->categories->first() ?? null; 'name' => (string) $artwork->title,
$contentType = $category ? $category->contentType : null; 'description' => (string) ($artwork->description ?? ''),
'url' => $meta['canonical'],
'contentUrl' => $meta['og_image'] ?? null,
'thumbnailUrl' => $presentMd['url'] ?? ($meta['og_image'] ?? null),
'encodingFormat' => 'image/webp',
'width' => !empty($meta['og_width']) ? (int) $meta['og_width'] : null,
'height' => !empty($meta['og_height']) ? (int) $meta['og_height'] : null,
'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $license,
'keywords' => $keywords !== '' ? $keywords : null,
];
if ($contentType) { $creativeWork = [
$rootCategories = Category::where('content_type_id', $contentType->id) '@context' => 'https://schema.org',
->whereNull('parent_id') '@type' => 'CreativeWork',
->orderBy('sort_order') 'name' => (string) $artwork->title,
->get(); 'description' => (string) ($artwork->description ?? ''),
} else { 'url' => $meta['canonical'],
$rootCategories = collect(); 'author' => $authorName ? ['@type' => 'Person', 'name' => $authorName] : null,
} 'datePublished' => optional($artwork->published_at)->toAtomString(),
'license' => $license,
'keywords' => $keywords !== '' ? $keywords : null,
'image' => $meta['og_image'] ?? null,
];
$subcategories = $category ? $category->children()->orderBy('sort_order')->get() : collect(); $imageObject = array_filter($imageObject, static fn ($value) => $value !== null && $value !== '');
// Provide an empty paginator to satisfy any shared pagination partials $creativeWork = array_filter($creativeWork, static fn ($value) => $value !== null && $value !== '');
$artworks = new LengthAwarePaginator([], 0, 24, 1, ['path' => request()->url()]);
$preloadSrcset = ($presentMd['url'] ?? '') . ' 640w, ' . ($presentLg['url'] ?? '') . ' 1280w, ' . ($presentXl['url'] ?? '') . ' 1920w';
@endphp @endphp
@if(!empty($presentLg['url']))
<link rel="preload" as="image"
href="{{ $presentLg['url'] }}"
imagesrcset="{{ trim($preloadSrcset) }}"
imagesizes="(min-width: 1280px) 1200px, (min-width: 768px) 90vw, 100vw">
@endif
<script type="application/ld+json">{!! json_encode($imageObject, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
<script type="application/ld+json">{!! json_encode($creativeWork, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG) !!}</script>
@endpush
@section('content') @section('content')
<div class="container-fluid legacy-page"> <div id="artwork-page"
@php Banner::ShowResponsiveAd(); @endphp data-artwork='@json($artworkData)'
data-related='@json($relatedItems)'
<div class="pt-0"> data-present-md='@json($presentMd)'
<div class="mx-auto w-full"> data-present-lg='@json($presentLg)'
<div class="flex min-h-[calc(100vh-64px)]"> data-present-xl='@json($presentXl)'
data-present-sq='@json($presentSq)'
<!-- SIDEBAR --> data-cdn='@json(rtrim((string) config("cdn.files_url", "https://files.skinbase.org"), "/"))'
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm"> data-canonical='@json($meta["canonical"])'>
<div class="p-4">
<button class="w-full h-12 rounded-xl bg-white/5 hover:bg-white/7 border border-white/5 flex items-center gap-3 px-4">
<span class="w-8 h-8 rounded-lg bg-white/5 inline-flex items-center justify-center">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>
</span>
<span class="text-sm text-white/90">Menu</span>
</button>
<div class="mt-6 text-sm text-neutral-400">
<div class="font-semibold text-white/80 mb-2">Main Categories:</div>
<ul class="space-y-2">
@foreach($rootCategories as $root)
<li>
<a class="flex items-center gap-2 hover:text-white" href="{{ $root->url }}"><span class="opacity-70">📁</span> {{ $root->name }}</a>
</li>
@endforeach
</ul>
<div class="mt-6 font-semibold text-white/80 mb-2">Browse Subcategories:</div>
<ul class="space-y-2 pr-2">
@foreach($subcategories as $sub)
<li><a class="hover:text-white {{ $category && $sub->id === $category->id ? 'font-semibold text-white' : 'text-neutral-400' }}" href="{{ $sub->url }}">{{ $sub->name }}</a></li>
@endforeach
</ul>
</div>
</div>
</aside>
<!-- MAIN -->
<main class="flex-1">
<div class="relative overflow-hidden nb-hero-radial">
<div class="absolute inset-0 opacity-35"></div>
<div class="relative px-6 py-8 md:px-10 md:py-10">
<div class="text-sm text-neutral-400">
@if($contentType)
<a class="hover:text-white" href="/{{ $contentType->slug }}">{{ $contentType->name }}</a>
@endif
@if($category)
@foreach ($category->breadcrumbs as $crumb)
<span class="opacity-50"></span> <a class="hover:text-white" href="{{ $crumb->url }}">{{ $crumb->name }}</a>
@endforeach
@endif
</div> </div>
@php @vite(['resources/js/Pages/ArtworkPage.jsx'])
$breadcrumbs = $category ? (is_array($category->breadcrumbs) ? $category->breadcrumbs : [$category]) : [];
$headerCategory = !empty($breadcrumbs) ? end($breadcrumbs) : ($category ?? null);
@endphp
<h1 class="mt-2 text-3xl md:text-4xl font-semibold tracking-tight text-white/95">{{ $headerCategory->name ?? $artwork->title }}</h1>
<section class="mt-5 bg-white/5 border border-white/10 rounded-2xl shadow-lg">
<div class="p-5 md:p-6">
<div class="text-lg font-semibold text-white/90">{{ $artwork->title }}</div>
<p class="mt-2 text-sm leading-6 text-neutral-400">{!! $artwork->description ?? ($headerCategory->description ?? ($contentType->name ?? 'Artwork')) !!}</p>
</div>
</section>
<div class="absolute left-0 right-0 bottom-0 h-36 nb-hero-fade pointer-events-none" aria-hidden="true"></div>
</div>
</div>
<!-- Artwork detail -->
<section class="px-6 pb-10 md:px-10">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="col-span-2">
<div class="rounded-2xl overflow-hidden bg-black/20 border border-white/10 shadow-lg">
<img src="{{ $artwork->thumbnail_url ?? '/images/placeholder.jpg' }}" alt="{{ $artwork->title }}" class="w-full h-auto object-contain" />
</div>
</div>
<aside class="p-4 bg-white/3 rounded-2xl border border-white/6">
<h3 class="font-semibold text-white">{{ $artwork->title }}</h3>
<p class="text-sm text-neutral-400 mt-2">{!! $artwork->description ?? 'No description provided.' !!}</p>
<div class="mt-4">
<a href="{{ $artwork->file_path ?? '#' }}" class="inline-block px-4 py-2 bg-indigo-600 text-white rounded">Download</a>
</div>
</aside>
</div>
@if(isset($similarItems) && $similarItems->isNotEmpty())
<section class="mt-8" data-similar-analytics data-algo-version="{{ $similarAlgoVersion ?? '' }}" data-artwork-id="{{ $artwork->id }}">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-lg md:text-xl font-semibold text-white/95">Similar artworks</h2>
</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4">
@foreach($similarItems as $item)
<article class="rounded-2xl overflow-hidden border border-white/10 bg-black/20 shadow-lg">
<a
href="{{ $item['url'] }}"
class="group block"
data-similar-click
data-similar-id="{{ $item['id'] }}"
data-similar-title="{{ e($item['title']) }}"
>
<div class="aspect-[16/10] bg-neutral-900">
<img
src="{{ $item['thumb'] }}"
@if(!empty($item['thumb_srcset'])) srcset="{{ $item['thumb_srcset'] }}" @endif
loading="lazy"
decoding="async"
alt="{{ $item['title'] }}"
class="h-full w-full object-cover transition group-hover:scale-[1.02]"
/>
</div>
<div class="p-3">
<div class="truncate text-sm font-medium text-white/90">{{ $item['title'] }}</div>
@if(!empty($item['author']))
<div class="mt-1 truncate text-xs text-neutral-400">by {{ $item['author'] }}</div>
@endif
</div>
</a>
</article>
@endforeach
</div>
</section>
@endif
</section>
</main>
</div>
</div>
</div>
</div> <!-- end .legacy-page -->
@php
$jsonLdType = str_starts_with((string) ($artwork->mime_type ?? ''), 'image/') ? 'ImageObject' : 'CreativeWork';
$keywords = $artwork->tags()->pluck('name')->values()->all();
$jsonLd = [
'@context' => 'https://schema.org',
'@type' => $jsonLdType,
'name' => (string) $artwork->title,
'description' => trim(strip_tags((string) ($artwork->description ?? ''))),
'author' => [
'@type' => 'Person',
'name' => (string) optional($artwork->user)->name,
],
'datePublished' => optional($artwork->published_at)->toAtomString(),
'url' => request()->url(),
'image' => (string) ($artwork->thumbnail_url ?? ''),
'keywords' => $keywords,
];
@endphp
<script type="application/ld+json">{!! json_encode($jsonLd, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) !!}</script>
@if(isset($similarItems) && $similarItems->isNotEmpty())
<script>
(function () {
var section = document.querySelector('[data-similar-analytics]');
if (!section) return;
var algoVersion = section.getAttribute('data-algo-version') || '';
var sourceArtworkId = section.getAttribute('data-artwork-id') || '';
var anchors = section.querySelectorAll('[data-similar-click]');
var impressionPayload = {
event: 'similar_artworks_impression',
source_artwork_id: sourceArtworkId,
algo_version: algoVersion,
item_ids: Array.prototype.map.call(anchors, function (anchor) {
return anchor.getAttribute('data-similar-id');
})
};
window.dataLayer = window.dataLayer || [];
function sendAnalytics(payload) {
var endpoint = '/api/analytics/similar-artworks';
var body = JSON.stringify(payload);
if (navigator.sendBeacon) {
var blob = new Blob([body], { type: 'application/json' });
navigator.sendBeacon(endpoint, blob);
return;
}
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body,
keepalive: true
}).catch(function () {
// ignore analytics transport errors
});
}
window.dataLayer.push(impressionPayload);
anchors.forEach(function (anchor, index) {
sendAnalytics({
event_type: 'impression',
source_artwork_id: Number(sourceArtworkId),
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
algo_version: algoVersion,
position: index + 1,
items_count: anchors.length
});
});
anchors.forEach(function (anchor, index) {
anchor.addEventListener('click', function () {
window.dataLayer.push({
event: 'similar_artworks_click',
source_artwork_id: sourceArtworkId,
algo_version: algoVersion,
similar_artwork_id: anchor.getAttribute('data-similar-id'),
similar_artwork_title: anchor.getAttribute('data-similar-title') || '',
position: index + 1
});
sendAnalytics({
event_type: 'click',
source_artwork_id: Number(sourceArtworkId),
similar_artwork_id: Number(anchor.getAttribute('data-similar-id')),
algo_version: algoVersion,
position: index + 1
});
});
});
})();
</script>
@endif
@endsection @endsection
@push('styles')
<style>
.nb-hero-fade {
background: linear-gradient(180deg, rgba(17,24,39,0) 0%, rgba(7,10,15,0.9) 60%, rgba(7,10,15,1) 100%);
}
</style>
@endpush

View File

@@ -5,6 +5,25 @@
$gridV2 = request()->query('grid') === 'v2'; $gridV2 = request()->query('grid') === 'v2';
@endphp @endphp
@php
$seoPage = max(1, (int) request()->query('page', 1));
$seoBase = url()->current();
$seoQ = request()->query(); unset($seoQ['page']);
$seoUrl = fn(int $p) => $seoBase . ($p > 1
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl() : null;
@endphp
@push('head')
<link rel="canonical" href="{{ $seoUrl($seoPage) }}">
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="index,follow">
@endpush
@section('content') @section('content')
<div class="container-fluid legacy-page"> <div class="container-fluid legacy-page">
@php Banner::ShowResponsiveAd(); @endphp @php Banner::ShowResponsiveAd(); @endphp

View File

@@ -8,6 +8,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="description" content="{{ $page_meta_description ?? '' }}"> <meta name="description" content="{{ $page_meta_description ?? '' }}">
<meta name="keywords" content="{{ $page_meta_keywords ?? '' }}"> <meta name="keywords" content="{{ $page_meta_keywords ?? '' }}">
@isset($page_robots) @isset($page_robots)
@@ -29,28 +30,6 @@
@vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js']) @vite(['resources/css/app.css','resources/css/nova-grid.css','resources/scss/nova.scss','resources/js/nova.js'])
<style> <style>
/* Gallery loading overlay */
.nova-loader-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 40;
}
.nova-loader-spinner {
width: 44px;
height: 44px;
border-radius: 50%;
border: 4px solid rgba(255,255,255,0.08);
border-top-color: rgba(255,255,255,0.9);
animation: novaSpin 0.9s linear infinite;
box-shadow: 0 6px 18px rgba(2,6,23,0.6);
backdrop-filter: blur(4px);
}
@keyframes novaSpin { to { transform: rotate(360deg); } }
/* Card enter animation */ /* Card enter animation */
.nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); } .nova-card-enter { opacity: 0; transform: translateY(10px) scale(0.995); }
.nova-card-enter.nova-card-enter-active { transition: transform 380ms cubic-bezier(.2,.9,.2,1), opacity 380ms ease-out; opacity: 1; transform: none; } .nova-card-enter.nova-card-enter-active { transition: transform 380ms cubic-bezier(.2,.9,.2,1), opacity 380ms ease-out; opacity: 1; transform: none; }

View File

@@ -2,6 +2,25 @@
@php($gridV2 = request()->query('grid') === 'v2') @php($gridV2 = request()->query('grid') === 'v2')
@php
$seoPage = max(1, (int) request()->query('page', 1));
$seoBase = url()->current();
$seoQ = request()->query(); unset($seoQ['page']);
$seoUrl = fn(int $p) => $seoBase . ($p > 1
? '?' . http_build_query(array_merge($seoQ, ['page' => $p]))
: (count($seoQ) ? '?' . http_build_query($seoQ) : ''));
$seoPrev = $seoPage > 1 ? $seoUrl($seoPage - 1) : null;
$seoNext = (isset($artworks) && method_exists($artworks, 'nextPageUrl'))
? $artworks->nextPageUrl() : null;
@endphp
@push('head')
<link rel="canonical" href="{{ $seoUrl($seoPage) }}">
@if($seoPrev)<link rel="prev" href="{{ $seoPrev }}">@endif
@if($seoNext)<link rel="next" href="{{ $seoNext }}">@endif
<meta name="robots" content="index,follow">
@endpush
@section('content') @section('content')
<div class="container-fluid legacy-page"> <div class="container-fluid legacy-page">
<div class="effect2 page-header-wrap"> <div class="effect2 page-header-wrap">

View File

@@ -1,85 +1,142 @@
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}} {{-- News and forum columns --}}
@php @php
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@endphp @endphp
<div class="row news-row"> <section class="px-6 pb-14 pt-2 md:px-10">
<div class="col-sm-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
@forelse ($forumNews as $item)
<div class="panel panel-skinbase effect2"> {{-- ── LEFT: Forum News ── --}}
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div> <div class="space-y-1">
<div class="panel-body"> <div class="flex items-center gap-2 mb-5">
<div class="text-muted news-head"> <span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-sky-500/15 text-sky-400">
Written by {{ $item->uname }} on {{ Carbon::parse($item->post_date)->format('j F Y \@ H:i') }} <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M7 8h10M7 12h6m-6 4h4M5 20H4a2 2 0 01-2-2V6a2 2 0 012-2h16a2 2 0 012 2v12a2 2 0 01-2 2h-1l-4 4-4-4z"/></svg>
</div> </span>
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!} <h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Forum News</h2>
<br>
<a class="clearfix btn btn-xs btn-info" href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}" title="{{ strip_tags($item->topic) }}">More</a>
</div>
</div>
@empty
<p>No forum news available.</p>
@endforelse
</div>
<div class="col-sm-6">
@forelse ($ourNews as $news)
<div class="panel panel-skinbase effect2">
<div class="panel-heading"><h3 class="panel-title">{{ $news->headline }}</h3></div>
<div class="panel-body">
<div class="text-muted news-head">
<i class="fa fa-user"></i> {{ $news->uname }}
<i class="fa fa-calendar"></i> {{ Carbon::parse($news->create_date)->format('j F Y \@ H:i') }}
<i class="fa fa-info"></i> {{ $news->category_name }}
<i class="fa fa-info"></i> {{ $news->views }} reads
<i class="fa fa-comment"></i> {{ $news->num_comments }} comments
</div> </div>
@if (!empty($news->picture)) @forelse ($forumNews as $item)
@php $nid = floor($news->news_id / 100); @endphp <article class="group rounded-xl bg-white/[0.03] border border-white/[0.06] hover:border-sky-500/30 hover:bg-white/[0.05] transition-all duration-200 p-4">
<div class="col-md-4"> <a href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}"
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}" class="img-responsive" alt="{{ $news->headline }}"> class="block text-sm font-semibold text-white/90 group-hover:text-sky-300 transition-colors leading-snug mb-1 line-clamp-2">
{{ $item->topic }}
</a>
<div class="flex items-center gap-3 text-xs text-white/40 mb-2">
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ $item->uname }}
</span>
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ Carbon::parse($item->post_date)->format('j M Y') }}
</span>
</div> </div>
<div class="col-md-8"> @if (!empty($item->preview))
{!! $news->preview !!} <p class="text-xs text-white/50 leading-relaxed line-clamp-3">{{ Str::limit(strip_tags($item->preview), 200) }}</p>
@endif
</article>
@empty
<div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-6 text-sm text-white/40 text-center">
No forum news available.
</div>
@endforelse
</div>
{{-- ── RIGHT: Site News + Info + Forum Activity ── --}}
<div class="space-y-8">
{{-- Site News --}}
<div>
<div class="flex items-center gap-2 mb-5">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-violet-500/15 text-violet-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10l6 6v8a2 2 0 01-2 2z"/><path stroke-linecap="round" stroke-linejoin="round" d="M13 4v6h6"/></svg>
</span>
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Site News</h2>
</div>
@forelse ($ourNews as $news)
@php $nid = floor($news->news_id / 100); @endphp
<article class="group rounded-xl bg-white/[0.03] border border-white/[0.06] hover:border-violet-500/30 hover:bg-white/[0.05] transition-all duration-200 p-4 mb-3 last:mb-0">
<a href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}"
class="block text-sm font-semibold text-white/90 group-hover:text-violet-300 transition-colors leading-snug mb-1 line-clamp-2">
{{ $news->headline }}
</a>
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40 mb-3">
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
{{ $news->uname }}
</span>
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
{{ Carbon::parse($news->create_date)->format('j M Y') }}
</span>
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ number_format($news->views) }}
</span>
<span class="inline-flex items-center gap-1">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ $news->num_comments }}
</span>
</div>
@if (!empty($news->picture))
<div class="flex gap-3">
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}"
class="w-20 h-14 object-cover rounded-lg flex-shrink-0 ring-1 ring-white/10"
alt="{{ e($news->headline) }}" loading="lazy">
<p class="text-xs text-white/50 leading-relaxed line-clamp-3">{!! Str::limit(strip_tags($news->preview ?? ''), 180) !!}</p>
</div> </div>
@else @else
{!! $news->preview !!} <p class="text-xs text-white/50 leading-relaxed line-clamp-3">{!! Str::limit(strip_tags($news->preview ?? ''), 240) !!}</p>
@endif @endif
</article>
<a class="clearfix btn btn-xs btn-info text-white" href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}">More</a>
</div>
</div>
@empty @empty
<p>No news available.</p> <div class="rounded-xl bg-white/[0.03] border border-white/[0.06] p-6 text-sm text-white/40 text-center">
No news available.
</div>
@endforelse @endforelse
{{-- Site info --}}
<div class="panel panel-default">
<div class="panel-heading"><strong>Info</strong></div>
<div class="panel-body">
<h4>Photography, Wallpapers and Skins... Thats Skinbase</h4>
<p>Skinbase is the site dedicated to <strong>Photography</strong>, <strong>Wallpapers</strong> and <strong>Skins</strong> for <u>popular applications</u> for every major operating system like Windows, Mac OS X, Linux, iOS and Android</p>
<em>Our members every day uploads new artworks to our site, so don&apos;t hesitate and check Skinbase frequently for updates. We also have forum where you can discuss with other members with anything.</em>
<p>On the site toolbar you can click on Categories and start browsing our atwork (<i>photo</i>, <i>desktop themes</i>, <i>pictures</i>) and of course you can <u>download</u> them for free!</p>
<p>We are also active on all major <b>social</b> sites, find us there too</p>
</div>
</div> </div>
{{-- Latest forum activity --}} {{-- About Skinbase --}}
<div class="panel panel-default activity-panel"> <div class="rounded-xl bg-gradient-to-br from-sky-500/10 to-violet-500/10 border border-white/[0.07] p-5">
<div class="panel-heading"><strong>Latest Forum Activity</strong></div> <div class="flex items-center gap-2 mb-3">
<div class="panel-body"> <span class="inline-flex items-center justify-center w-6 h-6 rounded-md bg-sky-500/20 text-sky-400">
<div class="list-group effect2"> <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
</span>
<h3 class="text-sm font-semibold text-white/80">About Skinbase</h3>
</div>
<p class="text-xs text-white/55 leading-relaxed">
Skinbase is dedicated to <span class="text-white/80 font-medium">Photography</span>, <span class="text-white/80 font-medium">Wallpapers</span> and <span class="text-white/80 font-medium">Skins</span> for popular applications on Windows, macOS, Linux, iOS and Android.
Browse categories, discover curated artwork, and download everything for free.
</p>
</div>
{{-- Latest Forum Activity --}}
<div>
<div class="flex items-center gap-2 mb-4">
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-emerald-500/15 text-emerald-400">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"/></svg>
</span>
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Forum Activity</h2>
</div>
<div class="rounded-xl border border-white/[0.06] overflow-hidden divide-y divide-white/[0.05]">
@forelse ($latestForumActivity as $topic) @forelse ($latestForumActivity as $topic)
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}"> <a href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}"
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span> class="flex items-center justify-between gap-3 px-4 py-3 text-sm text-white/70 hover:bg-white/[0.04] hover:text-white transition-colors group">
<span class="truncate group-hover:text-emerald-300 transition-colors">{{ $topic->topic }}</span>
<span class="flex-shrink-0 inline-flex items-center gap-1 text-xs text-white/35 group-hover:text-white/60 transition-colors">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ $topic->numPosts }}
</span>
</a> </a>
@empty @empty
<p>No recent forum activity.</p> <div class="px-4 py-5 text-sm text-white/40 text-center">No recent forum activity.</div>
@endforelse @endforelse
</div> </div>
</div> </div>
</div>{{-- end right column --}}
</div> </div>
</div> </section>
</div>

View File

@@ -1,9 +1,26 @@
@php($gridV2 = request()->query('grid') === 'v2') @php
$gridV2 = request()->query('grid') === 'v2';
$seoPage = (int) request()->query('page', 1);
$seoBase = url()->current();
$seoCanonical = $seoPage > 1 ? $seoBase . '?page=' . $seoPage : $seoBase;
$seoPrev = $seoPage > 1
? ($seoPage === 2 ? $seoBase : $seoBase . '?page=' . ($seoPage - 1))
: null;
$seoNext = (isset($latestUploads) && method_exists($latestUploads, 'hasMorePages') && $latestUploads->hasMorePages())
? $seoBase . '?page=' . ($seoPage + 1)
: null;
@endphp
@push('head')
<link rel="canonical" href="{{ $seoCanonical ?? url()->current() }}">
@if(!empty($seoPrev ?? null))<link rel="prev" href="{{ $seoPrev }}">@endif
@if(!empty($seoNext ?? null))<link rel="next" href="{{ $seoNext }}">@endif
@endpush
{{-- Latest uploads grid use same Nova gallery layout as /browse --}} {{-- Latest uploads grid use same Nova gallery layout as /browse --}}
<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads"> <section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
<div class="{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid> <div class="{{ ($gridV2 ?? false) ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}" data-gallery-grid>
@forelse($latestUploads as $upload) @forelse($latestUploads as $upload)
<x-artwork-card :art="$upload" /> <x-artwork-card :art="$upload" />
@empty @empty
@@ -24,7 +41,7 @@
</section> </section>
@push('styles') @push('styles')
@if(! $gridV2) @if(! ($gridV2 ?? false))
<style> <style>
[data-nova-gallery].is-enhanced [data-gallery-grid] { [data-nova-gallery].is-enhanced [data-gallery-grid] {
display: grid; display: grid;

View File

@@ -145,3 +145,21 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->na
Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update'); Route::put('{id}/tags', [\App\Http\Controllers\Api\ArtworkTagController::class, 'update'])->whereNumber('id')->name('update');
Route::delete('{id}/tags/{tag}', [\App\Http\Controllers\Api\ArtworkTagController::class, 'destroy'])->whereNumber('id')->name('destroy'); Route::delete('{id}/tags/{tag}', [\App\Http\Controllers\Api\ArtworkTagController::class, 'destroy'])->whereNumber('id')->name('destroy');
}); });
Route::middleware(['web', 'auth', 'normalize.username'])->group(function () {
Route::post('artworks/{id}/favorite', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'favorite'])
->whereNumber('id')
->name('api.artworks.favorite');
Route::post('artworks/{id}/like', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'like'])
->whereNumber('id')
->name('api.artworks.like');
Route::post('artworks/{id}/report', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'report'])
->whereNumber('id')
->name('api.artworks.report');
Route::post('users/{id}/follow', [\App\Http\Controllers\Api\ArtworkInteractionController::class, 'follow'])
->whereNumber('id')
->name('api.users.follow');
});

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\Dashboard\ManageController;
use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController; use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController;
use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\HomeController;
use App\Http\Controllers\Web\ArtController; use App\Http\Controllers\Web\ArtController;
use App\Http\Controllers\Web\ArtworkPageController;
use App\Http\Controllers\Misc\AvatarController as LegacyAvatarController; use App\Http\Controllers\Misc\AvatarController as LegacyAvatarController;
use App\Http\Controllers\Forum\ForumController; use App\Http\Controllers\Forum\ForumController;
use App\Http\Controllers\Community\NewsController; use App\Http\Controllers\Community\NewsController;
@@ -37,7 +38,7 @@ use Inertia\Inertia;
Route::get('/', [HomeController::class, 'index'])->name('legacy.home'); Route::get('/', [HomeController::class, 'index'])->name('legacy.home');
Route::get('/home', [HomeController::class, 'index']); Route::get('/home', [HomeController::class, 'index']);
Route::get('/art/{id}/{slug?}', [ArtController::class, 'show'])->where('id', '\\d+')->name('legacy.art.show'); Route::get('/art/{id}/{slug?}', [ArtworkPageController::class, 'show'])->where('id', '\\d+')->name('art.show');
Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\\d+'); Route::match(['get','post'], '/art/{id}/comment', [ArtController::class, 'show'])->where('id', '\\d+');
Route::get('/avatar/{id}/{name?}', [LegacyAvatarController::class, 'show'])->where('id', '\\d+')->name('legacy.avatar'); Route::get('/avatar/{id}/{name?}', [LegacyAvatarController::class, 'show'])->where('id', '\\d+')->name('legacy.avatar');

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,284 @@
# Page snapshot
```yaml
- generic [active] [ref=e1]:
- generic [ref=e2]:
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e10]: Internal Server Error
- button "Copy as Markdown" [ref=e11] [cursor=pointer]:
- img [ref=e12]
- generic [ref=e15]: Copy as Markdown
- generic [ref=e18]:
- generic [ref=e19]:
- heading "ErrorException" [level=1] [ref=e20]
- generic [ref=e22]: resources\views\web\home\uploads.blade.php:17
- paragraph [ref=e23]: Undefined variable $seoCanonical
- generic [ref=e24]:
- generic [ref=e25]:
- generic [ref=e26]:
- generic [ref=e27]: LARAVEL
- generic [ref=e28]: 12.51.0
- generic [ref=e29]:
- generic [ref=e30]: PHP
- generic [ref=e31]: 8.4.12
- generic [ref=e32]:
- img [ref=e33]
- text: UNHANDLED
- generic [ref=e36]: CODE 0
- generic [ref=e38]:
- generic [ref=e39]:
- img [ref=e40]
- text: "500"
- generic [ref=e43]:
- img [ref=e44]
- text: GET
- generic [ref=e47]: http://skinbase26.test
- button [ref=e48] [cursor=pointer]:
- img [ref=e49]
- generic [ref=e53]:
- generic [ref=e54]:
- generic [ref=e55]:
- img [ref=e57]
- heading "Exception trace" [level=3] [ref=e60]
- generic [ref=e61]:
- generic [ref=e62]:
- generic [ref=e63] [cursor=pointer]:
- generic [ref=e66]:
- code [ref=e70]:
- generic [ref=e71]: resources\views\web\home\uploads.blade.php
- generic [ref=e73]: resources\views\web\home\uploads.blade.php:17
- button [ref=e75]:
- img [ref=e76]
- code [ref=e84]:
- generic [ref=e85]: 12 $seoNext = (isset($latestUploads) && method_exists($latestUploads, 'hasMorePages') && $latestUploads->hasMorePages())
- generic [ref=e86]: 13 ? $seoBase . '?page=' . ($seoPage + 1)
- generic [ref=e87]: "14 : null;"
- generic [ref=e88]: 15@endphp
- generic [ref=e89]: "16<link rel=\"canonical\" href=\"{{ $seoCanonical }}\">"
- generic [ref=e90]: "17@if($seoPrev)<link rel=\"prev\" href=\"{{ $seoPrev }}\">@endif"
- generic [ref=e91]: "18@if($seoNext)<link rel=\"next\" href=\"{{ $seoNext }}\">@endif"
- generic [ref=e92]: 19@endpush
- generic [ref=e93]: "20"
- generic [ref=e94]: "21{{-- Latest uploads grid — use same Nova gallery layout as /browse --}}"
- generic [ref=e95]: 22<section class="px-6 pb-10 pt-6 md:px-10" data-nova-gallery data-gallery-type="home-uploads">
- generic [ref=e96]: "23 <div class=\"{{ $gridV2 ? 'gallery' : 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6' }}\" data-gallery-grid>"
- generic [ref=e97]: 24 @forelse($latestUploads as $upload)
- generic [ref=e98]: 25 <x-artwork-card :art="$upload" />
- generic [ref=e99]: 26 @empty
- generic [ref=e100]: 27 <div class="panel panel-default effect2">
- generic [ref=e101]: 28 <div class="panel-heading"><strong>No uploads yet</strong></div>
- generic [ref=e102]: "29"
- generic [ref=e104] [cursor=pointer]:
- img [ref=e105]
- generic [ref=e109]: 7 vendor frames
- button [ref=e110]:
- img [ref=e111]
- generic [ref=e116] [cursor=pointer]:
- generic [ref=e119]:
- code [ref=e123]:
- generic [ref=e124]: resources\views\web\home.blade.php
- generic [ref=e126]: resources\views\web\home.blade.php:13
- button [ref=e128]:
- img [ref=e129]
- generic [ref=e134] [cursor=pointer]:
- img [ref=e135]
- generic [ref=e139]: 59 vendor frames
- button [ref=e140]:
- img [ref=e141]
- generic [ref=e146] [cursor=pointer]:
- generic [ref=e149]:
- code [ref=e153]:
- generic [ref=e154]: public\index.php
- generic [ref=e156]: public\index.php:20
- button [ref=e158]:
- img [ref=e159]
- generic [ref=e163]:
- generic [ref=e164]:
- generic [ref=e165]:
- img [ref=e167]
- heading "Queries" [level=3] [ref=e169]
- generic [ref=e171]: 1-10 of 10
- generic [ref=e172]:
- generic [ref=e173]:
- generic [ref=e174]:
- generic [ref=e175]:
- img [ref=e176]
- generic [ref=e178]: mysql
- code [ref=e182]:
- generic [ref=e183]: "select * from `sessions` where `id` = 'n1Ak5yRyISdBy7mP0qn64fgHABeJHXvVYdXgSqFc' limit 1"
- generic [ref=e184]: 5.78ms
- generic [ref=e185]:
- generic [ref=e186]:
- generic [ref=e187]:
- img [ref=e188]
- generic [ref=e190]: mysql
- code [ref=e194]:
- generic [ref=e195]: "select count(*) as aggregate from `artworks` inner join `artwork_features` as `af` on `af`.`artwork_id` = `artworks`.`id` where `artworks`.`deleted_at` is null and `artworks`.`is_approved` = 1 and `artworks`.`is_public` = 1 and `artworks`.`deleted_at` is null and `artworks`.`published_at` is not null and `artworks`.`published_at` <= '2026-02-22 15:47:31' and `artworks`.`deleted_at` is null"
- generic [ref=e196]: 567.8ms
- generic [ref=e197]:
- generic [ref=e198]:
- generic [ref=e199]:
- img [ref=e200]
- generic [ref=e202]: mysql
- code [ref=e206]:
- generic [ref=e207]: "select `artworks`.* from `artworks` inner join `artwork_features` as `af` on `af`.`artwork_id` = `artworks`.`id` where `artworks`.`deleted_at` is null and `artworks`.`is_approved` = 1 and `artworks`.`is_public` = 1 and `artworks`.`deleted_at` is null and `artworks`.`published_at` is not null and `artworks`.`published_at` <= '2026-02-22 15:47:31' and `artworks`.`deleted_at` is null order by `af`.`featured_at` desc, `artworks`.`published_at` desc limit 39 offset 0"
- generic [ref=e208]: 38.44ms
- generic [ref=e209]:
- generic [ref=e210]:
- generic [ref=e211]:
- img [ref=e212]
- generic [ref=e214]: mysql
- code [ref=e218]:
- generic [ref=e219]: "select `id`, `name` from `users` where `users`.`id` in (316, 681, 29582, 40023, 45404, 45691, 46938, 57122, 77698, 78496, 166261) and `users`.`deleted_at` is null"
- generic [ref=e220]: 9.4ms
- generic [ref=e221]:
- generic [ref=e222]:
- generic [ref=e223]:
- img [ref=e224]
- generic [ref=e226]: mysql
- code [ref=e230]:
- generic [ref=e231]: "select `categories`.`id`, `categories`.`content_type_id`, `categories`.`parent_id`, `categories`.`name`, `categories`.`slug`, `categories`.`sort_order`, `artwork_category`.`artwork_id` as `pivot_artwork_id`, `artwork_category`.`category_id` as `pivot_category_id` from `categories` inner join `artwork_category` on `categories`.`id` = `artwork_category`.`category_id` where `artwork_category`.`artwork_id` in (3165, 3333, 68339, 68363, 68425, 68474, 68483, 68489, 68491, 68537, 68568, 68604, 68624, 68677, 68686, 68701, 68740, 68752, 68792, 68810, 68822, 68842, 68863, 68881, 68895, 68908, 68925, 68929, 68973, 68999, 69018, 69026, 69051, 69069, 69078, 69095, 69116, 69164, 69191) and `categories`.`deleted_at` is null"
- generic [ref=e232]: 6.39ms
- generic [ref=e233]:
- generic [ref=e234]:
- generic [ref=e235]:
- img [ref=e236]
- generic [ref=e238]: mysql
- code [ref=e242]:
- generic [ref=e243]: "select * from `artworks` where `artworks`.`deleted_at` is null and `artworks`.`is_approved` = 1 and `artworks`.`is_public` = 1 and `artworks`.`deleted_at` is null and `artworks`.`published_at` is not null and `artworks`.`published_at` <= '2026-02-22 15:47:31' and `artworks`.`deleted_at` is null order by `published_at` desc limit 20"
- generic [ref=e244]: 294.44ms
- generic [ref=e245]:
- generic [ref=e246]:
- generic [ref=e247]:
- img [ref=e248]
- generic [ref=e250]: mysql
- code [ref=e254]:
- generic [ref=e255]: "select t1.id as topic_id, t1.title as topic, COALESCE(u.name, 'Unknown') as uname, t1.created_at as post_date, t1.content as preview from `forum_threads` as `t1` left join `users` as `u` on `t1`.`user_id` = `u`.`id` left join `forum_categories` as `c` on `t1`.`category_id` = `c`.`id` where `t1`.`deleted_at` is null and (`t1`.`category_id` = 2876 or `c`.`slug` in ('news', 'forum-news')) order by `t1`.`created_at` desc limit 8"
- generic [ref=e256]: 20.85ms
- generic [ref=e257]:
- generic [ref=e258]:
- generic [ref=e259]:
- img [ref=e260]
- generic [ref=e262]: mysql
- code [ref=e266]:
- generic [ref=e267]: "select t1.id as topic_id, t1.title as topic, COUNT(p.id) as numPosts from `forum_threads` as `t1` left join `forum_categories` as `c` on `t1`.`category_id` = `c`.`id` left join `forum_posts` as `p` on `p`.`thread_id` = `t1`.`id` and `p`.`deleted_at` is null where `t1`.`deleted_at` is null and (`t1`.`category_id` <> 2876 or `t1`.`category_id` is null) and (`c`.`slug` is null or `c`.`slug` not in ('news', 'forum-news')) group by `t1`.`id`, `t1`.`title` order by `t1`.`last_post_at` desc, `t1`.`created_at` desc limit 10"
- generic [ref=e268]: 514.27ms
- generic [ref=e269]:
- generic [ref=e270]:
- generic [ref=e271]:
- img [ref=e272]
- generic [ref=e274]: mysql
- code [ref=e278]:
- generic [ref=e279]: "select * from `user_profiles` where `user_profiles`.`user_id` = 316 and `user_profiles`.`user_id` is not null limit 1"
- generic [ref=e280]: 2.56ms
- generic [ref=e281]:
- generic [ref=e282]:
- generic [ref=e283]:
- img [ref=e284]
- generic [ref=e286]: mysql
- code [ref=e290]:
- generic [ref=e291]: "select * from `artwork_comments` where `artwork_comments`.`artwork_id` = 3333 and `artwork_comments`.`artwork_id` is not null and `artwork_comments`.`deleted_at` is null"
- generic [ref=e292]: 0.82ms
- generic [ref=e294]:
- generic [ref=e295]:
- heading "Headers" [level=2] [ref=e296]
- generic [ref=e297]:
- generic [ref=e298]:
- generic [ref=e299]: accept-encoding
- generic [ref=e301]: gzip, deflate
- generic [ref=e302]:
- generic [ref=e303]: accept
- generic [ref=e305]: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
- generic [ref=e306]:
- generic [ref=e307]: accept-language
- generic [ref=e309]: en-US
- generic [ref=e310]:
- generic [ref=e311]: user-agent
- generic [ref=e313]: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36
- generic [ref=e314]:
- generic [ref=e315]: upgrade-insecure-requests
- generic [ref=e317]: "1"
- generic [ref=e318]:
- generic [ref=e319]: connection
- generic [ref=e321]: close
- generic [ref=e322]:
- generic [ref=e323]: host
- generic [ref=e325]: skinbase26.test
- generic [ref=e326]:
- heading "Body" [level=2] [ref=e327]
- generic [ref=e328]: // No request body
- generic [ref=e329]:
- heading "Routing" [level=2] [ref=e330]
- generic [ref=e331]:
- generic [ref=e332]:
- generic [ref=e333]: controller
- generic [ref=e335]: App\Http\Controllers\Web\HomeController@index
- generic [ref=e336]:
- generic [ref=e337]: route name
- generic [ref=e339]: legacy.home
- generic [ref=e340]:
- generic [ref=e341]: middleware
- generic [ref=e343]: web
- generic [ref=e344]:
- heading "Routing parameters" [level=2] [ref=e345]
- generic [ref=e346]: // No routing parameters
- generic [ref=e349]:
- img [ref=e351]
- img [ref=e3389]
- generic [ref=e6427]:
- generic [ref=e6429]:
- generic [ref=e6431]:
- generic [ref=e6432] [cursor=pointer]:
- generic: Request
- generic [ref=e6433]: "500"
- generic [ref=e6434] [cursor=pointer]:
- generic: Exceptions
- generic [ref=e6435]: "3"
- generic [ref=e6436] [cursor=pointer]:
- generic: Messages
- generic [ref=e6437]: "2"
- generic [ref=e6438] [cursor=pointer]:
- generic: Timeline
- generic [ref=e6439] [cursor=pointer]:
- generic: Views
- generic [ref=e6440]: "370"
- generic [ref=e6441] [cursor=pointer]:
- generic: Queries
- generic [ref=e6442]: "12"
- generic [ref=e6443] [cursor=pointer]:
- generic: Models
- generic [ref=e6444]: "109"
- generic [ref=e6445]:
- generic [ref=e6453] [cursor=pointer]: GET /
- generic [ref=e6454] [cursor=pointer]:
- generic: 3.22s
- generic [ref=e6456] [cursor=pointer]:
- generic: 35MB
- generic [ref=e6458] [cursor=pointer]:
- generic: 12.x
- generic [ref=e6460]:
- generic [ref=e6462]:
- generic:
- list
- generic [ref=e6464]:
- list [ref=e6465]
- textbox "Search" [ref=e6468]
- generic [ref=e6469]:
- list
- generic [ref=e6471]:
- list
- list [ref=e6476]
- generic [ref=e6478]:
- generic:
- list
- generic [ref=e6480]:
- list [ref=e6481]
- textbox "Search" [ref=e6484]
- generic [ref=e6485]:
- list
- generic [ref=e6487]:
- generic:
- list
```

View File

@@ -0,0 +1,57 @@
<?php
use App\Services\ArtworkService;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/** Return an empty LengthAwarePaginator with the given path. */
function emptyPaginator(string $path = '/'): LengthAwarePaginator
{
return (new LengthAwarePaginator(collect(), 0, 20, 1))->setPath($path);
}
beforeEach(function () {
// Swap ArtworkService so tests need no database
$this->artworksMock = Mockery::mock(ArtworkService::class);
$this->artworksMock->shouldReceive('getFeaturedArtworks')->andReturn(emptyPaginator('/'))->byDefault();
$this->artworksMock->shouldReceive('getLatestArtworks')->andReturn(collect())->byDefault();
$this->app->instance(ArtworkService::class, $this->artworksMock);
});
it('renders the home page successfully', function () {
$this->get('/')
->assertStatus(200);
});
it('renders the home page with grid=v2 without errors', function () {
$this->get('/?grid=v2')
->assertStatus(200);
});
it('home page contains the gallery section', function () {
$this->get('/')
->assertStatus(200)
->assertSee('data-nova-gallery', false);
});
it('home page includes a canonical link tag', function () {
$this->get('/')
->assertStatus(200)
->assertSee('rel="canonical"', false);
});
it('home page with ?page=2 renders without errors', function () {
// getLatestArtworks() returns a plain Collection (no pagination),
// so seoNext/seoPrev for home are always null — but the page must still render cleanly.
$this->get('/?page=2')
->assertStatus(200)
->assertSee('rel="canonical"', false);
});
it('home page does not throw undefined variable errors', function () {
// If any Blade variable is undefined an exception is thrown → non-200 status
// This test explicitly guards against regressions on $gridV2 / $seoCanonical
expect(
fn () => $this->get('/')->assertStatus(200)
)->not->toThrow(\ErrorException::class);
});

View File

@@ -10,7 +10,8 @@ export default defineConfig({
'resources/scss/nova.scss', 'resources/scss/nova.scss',
'resources/js/nova.js', 'resources/js/nova.js',
'resources/js/entry-topbar.jsx', 'resources/js/entry-topbar.jsx',
'resources/js/upload.jsx' 'resources/js/upload.jsx',
'resources/js/Pages/ArtworkPage.jsx'
], ],
refresh: true, refresh: true,
}), }),