fixed gallery
This commit is contained in:
193
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal file
193
app/Http/Controllers/Api/ArtworkInteractionController.php
Normal 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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Services\LegacyService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtController extends Controller
|
||||
{
|
||||
@@ -18,29 +19,23 @@ class ArtController extends Controller
|
||||
|
||||
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 {
|
||||
$art = \App\Models\Artwork::find((int) $id);
|
||||
if ($art && !empty($art->slug)) {
|
||||
if ($slug !== $art->slug) {
|
||||
// attempt to derive contentType and category for route
|
||||
$category = $art->categories()->with('contentType')->first();
|
||||
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);
|
||||
}
|
||||
if ($art && $request->isMethod('get')) {
|
||||
$canonicalSlug = Str::slug((string) ($art->slug ?: $art->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $art->id;
|
||||
}
|
||||
|
||||
return redirect()->route('art.show', [
|
||||
'id' => (int) $art->id,
|
||||
'slug' => $canonicalSlug,
|
||||
], 301);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue rendering legacy view
|
||||
// keep legacy fallback below
|
||||
}
|
||||
if ($request->isMethod('post') && $request->input('action') === 'store_comment') {
|
||||
if (auth()->check()) {
|
||||
|
||||
116
app/Http/Controllers/Web/ArtworkPageController.php
Normal file
116
app/Http/Controllers/Web/ArtworkPageController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
<?php
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Services\ThumbnailPresenter;
|
||||
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
|
||||
{
|
||||
@@ -11,68 +13,105 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
if ($this instanceof MissingValue || $this->resource instanceof MissingValue) {
|
||||
return [];
|
||||
}
|
||||
$get = function ($key) {
|
||||
$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', ''), '/');
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
$xl = ThumbnailPresenter::present($this->resource, 'xl');
|
||||
$sq = ThumbnailPresenter::present($this->resource, 'sq');
|
||||
|
||||
$buildOriginalUrl = static function (string $hashValue, string $extValue) use ($filesBase): ?string {
|
||||
$normalizedHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hashValue));
|
||||
$normalizedExt = strtolower((string) preg_replace('/[^a-z0-9]/', '', $extValue));
|
||||
if ($normalizedHash === '' || $normalizedExt === '') return null;
|
||||
$h1 = substr($normalizedHash, 0, 2);
|
||||
$h2 = substr($normalizedHash, 2, 2);
|
||||
if ($h1 === '' || $h2 === '' || $filesBase === '') return null;
|
||||
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $this->id;
|
||||
}
|
||||
|
||||
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 [
|
||||
'slug' => $get('slug'),
|
||||
'title' => $get('title'),
|
||||
'description' => $get('description'),
|
||||
'width' => $get('width'),
|
||||
'height' => $get('height'),
|
||||
|
||||
// File URLs are derived from hash/ext (no DB path dependency)
|
||||
'id' => (int) $this->id,
|
||||
'slug' => (string) $this->slug,
|
||||
'title' => (string) $this->title,
|
||||
'description' => (string) ($this->description ?? ''),
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'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' => [
|
||||
'name' => $get('file_name') ?? null,
|
||||
'url' => $this->when(! empty($hash) && ! empty($fileExt), fn() => $buildOriginalUrl($hash, $fileExt)),
|
||||
'size' => $get('file_size') ?? null,
|
||||
'mime_type' => $get('mime_type') ?? null,
|
||||
'url' => $lg['url'] ?? null,
|
||||
'srcset' => ThumbnailPresenter::srcsetForArtwork($this->resource),
|
||||
'mime_type' => 'image/webp',
|
||||
],
|
||||
|
||||
'categories' => $this->whenLoaded('categories', function () {
|
||||
return $this->categories->map(fn($c) => [
|
||||
'slug' => $c->slug ?? null,
|
||||
'name' => $c->name ?? null,
|
||||
])->values();
|
||||
}),
|
||||
|
||||
'published_at' => $this->whenNotNull($get('published_at') ? $this->published_at->toAtomString() : null),
|
||||
|
||||
'urls' => [
|
||||
'canonical' => $get('canonical_url') ?? null,
|
||||
'user' => [
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => (string) ($this->user?->name ?? ''),
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'followers_count' => $followerCount,
|
||||
],
|
||||
'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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,12 +54,7 @@ class AvatarService
|
||||
{
|
||||
$this->assertImageManagerAvailable();
|
||||
$this->assertStorageIsAllowed();
|
||||
$this->assertSecureImageUpload($file);
|
||||
|
||||
$binary = file_get_contents($file->getRealPath());
|
||||
if ($binary === false || $binary === '') {
|
||||
throw new RuntimeException('Uploaded avatar file is empty or unreadable.');
|
||||
}
|
||||
$binary = $this->assertSecureImageUpload($file);
|
||||
|
||||
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());
|
||||
if (!in_array($extension, self::ALLOWED_EXTENSIONS, true)) {
|
||||
throw new RuntimeException('Unsupported avatar file extension.');
|
||||
@@ -242,7 +241,12 @@ class AvatarService
|
||||
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 === '') {
|
||||
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) {
|
||||
throw new RuntimeException('Uploaded avatar is not a valid image.');
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,75 @@
|
||||
<?php
|
||||
namespace App\Services;
|
||||
|
||||
use App\Services\ThumbnailService;
|
||||
use App\Models\Artwork;
|
||||
|
||||
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.
|
||||
* Returns ['id' => int|null, 'title' => string, 'url' => string, 'srcset' => string|null]
|
||||
*/
|
||||
public static function present($item, string $size = 'md'): array
|
||||
{
|
||||
$uext = 'jpg';
|
||||
$isEloquent = $item instanceof \Illuminate\Database\Eloquent\Model;
|
||||
|
||||
$size = self::normalizeSize($size);
|
||||
$id = null;
|
||||
$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) {
|
||||
$id = $item->id ?? null;
|
||||
$title = $item->name ?? '';
|
||||
$title = $item->title ?? ($item->name ?? '');
|
||||
$url = $item->thumb_url ?? $item->thumb ?? '';
|
||||
$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
|
||||
@@ -35,15 +82,87 @@ class ThumbnailPresenter
|
||||
|
||||
// If array contains direct hash/thumb_ext, use CDN fromHash
|
||||
$hash = $item['hash'] ?? null;
|
||||
$thumbExt = $item['thumb_ext'] ?? ($item['ext'] ?? $uext);
|
||||
$thumbExt = 'webp';
|
||||
if (!empty($hash) && !empty($thumbExt)) {
|
||||
$url = ThumbnailService::fromHash($hash, $thumbExt, $size) ?: ThumbnailService::url(null, $id, $thumbExt, 6);
|
||||
$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
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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 = [
|
||||
'sm' => ['height' => 240, 'quality' => 78, 'dir' => 'sm'],
|
||||
'md' => ['height' => 360, 'quality' => 82, 'dir' => 'md'],
|
||||
'lg' => ['height' => 1200, 'quality' => 85, 'dir' => 'lg'],
|
||||
'xl' => ['height' => 2400, 'quality' => 90, 'dir' => 'xl'],
|
||||
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
|
||||
'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true],
|
||||
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb
|
||||
'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 (!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) ?: '';
|
||||
}
|
||||
|
||||
@@ -39,7 +57,7 @@ class ThumbnailService
|
||||
if ($art) {
|
||||
$hash = $art->hash ?? 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)) {
|
||||
return self::fromHash($hash, $extToUse, $sizeKey) ?: '';
|
||||
}
|
||||
@@ -68,11 +86,14 @@ class ThumbnailService
|
||||
public static function fromHash(?string $hash, ?string $ext, string $sizeKey = 'md'): ?string
|
||||
{
|
||||
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';
|
||||
$dir = self::THUMB_SIZES[$sizeKey]['dir'] ?? $sizeKey;
|
||||
$h = $hash;
|
||||
$h1 = substr($h, 0, 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
|
||||
{
|
||||
$a = self::fromHash($hash, $ext, 'sm');
|
||||
$b = self::fromHash($hash, $ext, 'md');
|
||||
$a = self::fromHash($hash, $ext, 'thumb'); // 320px
|
||||
$b = self::fromHash($hash, $ext, 'md'); // 1024px
|
||||
if (!$a || !$b) return null;
|
||||
return $a . ' 320w, ' . $b . ' 600w';
|
||||
return $a . ' 320w, ' . $b . ' 1024w';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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 VIRTUAL_OBSERVER_MARGIN = '800px';
|
||||
|
||||
function toArray(list) {
|
||||
return Array.prototype.slice.call(list || []);
|
||||
@@ -28,6 +29,14 @@
|
||||
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) {
|
||||
var box = root.querySelector('[data-gallery-skeleton]');
|
||||
if (!box) return;
|
||||
@@ -81,37 +90,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyVirtualizationHints(root) {
|
||||
var grid = root.querySelector('[data-gallery-grid]');
|
||||
if (!grid) return;
|
||||
var cards = toArray(grid.querySelectorAll('.nova-card'));
|
||||
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) {
|
||||
cards.forEach(function (card) {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
function activateBlurPreviews(root) {
|
||||
var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
|
||||
imgs.forEach(function (img) {
|
||||
if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
|
||||
img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
|
||||
img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var viewportTop = window.scrollY;
|
||||
var viewportBottom = viewportTop + window.innerHeight;
|
||||
|
||||
cards.forEach(function (card) {
|
||||
var rect = card.getBoundingClientRect();
|
||||
var top = rect.top + viewportTop;
|
||||
var bottom = rect.bottom + viewportTop;
|
||||
var farAbove = bottom < viewportTop - 1400;
|
||||
var farBelow = top > viewportBottom + 2600;
|
||||
|
||||
if (farAbove || farBelow) {
|
||||
var h = Math.max(160, rect.height || 220);
|
||||
card.style.contentVisibility = 'auto';
|
||||
card.style.containIntrinsicSize = Math.round(h) + 'px';
|
||||
// Create an IntersectionObserver that applies content-visibility hints
|
||||
// to cards that leave the viewport. Using an observer avoids calling
|
||||
// getBoundingClientRect() in a scroll handler (which forces layout).
|
||||
// entry.boundingClientRect gives us the last rendered height without
|
||||
// triggering a synchronous layout recalculation.
|
||||
function makeVirtualObserver() {
|
||||
if (!('IntersectionObserver' in window)) return null;
|
||||
return new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
var card = entry.target;
|
||||
if (entry.isIntersecting) {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
} else {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
// Capture the last-known rendered height before hiding.
|
||||
// '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) {
|
||||
@@ -145,13 +154,40 @@
|
||||
if (!grid) return;
|
||||
|
||||
root.classList.add('is-enhanced');
|
||||
activateBlurPreviews(root);
|
||||
|
||||
var state = {
|
||||
loading: false,
|
||||
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
|
||||
};
|
||||
|
||||
// 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() {
|
||||
// Apply masonry synchronously first — the card already has inline aspect-ratio
|
||||
// set from image dimensions, so getBoundingClientRect() returns the correct
|
||||
@@ -167,21 +203,18 @@
|
||||
if (!GRID_V2_ENABLED) {
|
||||
applyMasonry(root);
|
||||
}
|
||||
applyVirtualizationHints(root);
|
||||
});
|
||||
}
|
||||
|
||||
var rafId = null;
|
||||
function onScrollOrResize() {
|
||||
if (rafId) return;
|
||||
rafId = window.requestAnimationFrame(function () {
|
||||
rafId = null;
|
||||
applyVirtualizationHints(root);
|
||||
// Virtualization check runs after the initial render too, in case the
|
||||
// page was loaded mid-scroll with many pre-rendered cards.
|
||||
checkVirtualization([]);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
|
||||
@@ -189,7 +222,7 @@
|
||||
setSkeleton(root, true, skeletonCount);
|
||||
|
||||
try {
|
||||
var response = await window.fetch(state.nextUrl, {
|
||||
var response = await window.fetch(fetchUrl, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
@@ -201,6 +234,7 @@
|
||||
if (!state.nextUrl || result.appended === 0) {
|
||||
state.done = true;
|
||||
}
|
||||
activateBlurPreviews(root);
|
||||
|
||||
// Animate appended cards
|
||||
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
|
||||
@@ -212,6 +246,7 @@
|
||||
});
|
||||
|
||||
relayout();
|
||||
checkVirtualization(appendedCards);
|
||||
// After new cards appended, move the trigger to remain one-row-before-last.
|
||||
placeTrigger();
|
||||
} catch (e) {
|
||||
@@ -275,10 +310,8 @@
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
relayout();
|
||||
onScrollOrResize();
|
||||
placeTrigger();
|
||||
}, { passive: true });
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true });
|
||||
|
||||
relayout();
|
||||
placeTrigger();
|
||||
|
||||
@@ -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 VIRTUAL_OBSERVER_MARGIN = '800px';
|
||||
|
||||
function toArray(list) {
|
||||
return Array.prototype.slice.call(list || []);
|
||||
@@ -28,6 +29,14 @@
|
||||
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) {
|
||||
var box = root.querySelector('[data-gallery-skeleton]');
|
||||
if (!box) return;
|
||||
@@ -81,37 +90,37 @@
|
||||
});
|
||||
}
|
||||
|
||||
function applyVirtualizationHints(root) {
|
||||
var grid = root.querySelector('[data-gallery-grid]');
|
||||
if (!grid) return;
|
||||
var cards = toArray(grid.querySelectorAll('.nova-card'));
|
||||
if (cards.length <= MAX_DOM_CARDS_FOR_VIRTUAL_HINT) {
|
||||
cards.forEach(function (card) {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
function activateBlurPreviews(root) {
|
||||
var imgs = toArray(root.querySelectorAll('img[data-blur-preview]'));
|
||||
imgs.forEach(function (img) {
|
||||
if (img.complete && img.naturalWidth > 0) { img.classList.add('is-loaded'); return; }
|
||||
img.addEventListener('load', function () { img.classList.add('is-loaded'); }, { once: true });
|
||||
img.addEventListener('error', function () { img.classList.add('is-loaded'); }, { once: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
var viewportTop = window.scrollY;
|
||||
var viewportBottom = viewportTop + window.innerHeight;
|
||||
|
||||
cards.forEach(function (card) {
|
||||
var rect = card.getBoundingClientRect();
|
||||
var top = rect.top + viewportTop;
|
||||
var bottom = rect.bottom + viewportTop;
|
||||
var farAbove = bottom < viewportTop - 1400;
|
||||
var farBelow = top > viewportBottom + 2600;
|
||||
|
||||
if (farAbove || farBelow) {
|
||||
var h = Math.max(160, rect.height || 220);
|
||||
card.style.contentVisibility = 'auto';
|
||||
card.style.containIntrinsicSize = Math.round(h) + 'px';
|
||||
// Create an IntersectionObserver that applies content-visibility hints
|
||||
// to cards that leave the viewport. Using an observer avoids calling
|
||||
// getBoundingClientRect() in a scroll handler (which forces layout).
|
||||
// entry.boundingClientRect gives us the last rendered height without
|
||||
// triggering a synchronous layout recalculation.
|
||||
function makeVirtualObserver() {
|
||||
if (!('IntersectionObserver' in window)) return null;
|
||||
return new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
var card = entry.target;
|
||||
if (entry.isIntersecting) {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
} else {
|
||||
card.style.contentVisibility = '';
|
||||
card.style.containIntrinsicSize = '';
|
||||
// Capture the last-known rendered height before hiding.
|
||||
// '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) {
|
||||
@@ -145,13 +154,40 @@
|
||||
if (!grid) return;
|
||||
|
||||
root.classList.add('is-enhanced');
|
||||
activateBlurPreviews(root);
|
||||
|
||||
var state = {
|
||||
loading: false,
|
||||
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
|
||||
};
|
||||
|
||||
// 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() {
|
||||
// Apply masonry synchronously first — the card already has inline aspect-ratio
|
||||
// set from image dimensions, so getBoundingClientRect() returns the correct
|
||||
@@ -167,21 +203,18 @@
|
||||
if (!GRID_V2_ENABLED) {
|
||||
applyMasonry(root);
|
||||
}
|
||||
applyVirtualizationHints(root);
|
||||
});
|
||||
}
|
||||
|
||||
var rafId = null;
|
||||
function onScrollOrResize() {
|
||||
if (rafId) return;
|
||||
rafId = window.requestAnimationFrame(function () {
|
||||
rafId = null;
|
||||
applyVirtualizationHints(root);
|
||||
// Virtualization check runs after the initial render too, in case the
|
||||
// page was loaded mid-scroll with many pre-rendered cards.
|
||||
checkVirtualization([]);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
var sampleCards = toArray(grid.querySelectorAll('.nova-card'));
|
||||
@@ -189,7 +222,7 @@
|
||||
setSkeleton(root, true, skeletonCount);
|
||||
|
||||
try {
|
||||
var response = await window.fetch(state.nextUrl, {
|
||||
var response = await window.fetch(fetchUrl, {
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
});
|
||||
@@ -201,6 +234,7 @@
|
||||
if (!state.nextUrl || result.appended === 0) {
|
||||
state.done = true;
|
||||
}
|
||||
activateBlurPreviews(root);
|
||||
|
||||
// Animate appended cards
|
||||
var appendedCards = toArray(grid.querySelectorAll('.nova-card')).slice(-result.appended);
|
||||
@@ -212,6 +246,7 @@
|
||||
});
|
||||
|
||||
relayout();
|
||||
checkVirtualization(appendedCards);
|
||||
// After new cards appended, move the trigger to remain one-row-before-last.
|
||||
placeTrigger();
|
||||
} catch (e) {
|
||||
@@ -275,10 +310,8 @@
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
relayout();
|
||||
onScrollOrResize();
|
||||
placeTrigger();
|
||||
}, { passive: true });
|
||||
window.addEventListener('scroll', onScrollOrResize, { passive: true });
|
||||
|
||||
relayout();
|
||||
placeTrigger();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
resources/js/Pages/ArtworkPage.jsx
Normal file
69
resources/js/Pages/ArtworkPage.jsx
Normal 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
|
||||
136
resources/js/components/artwork/ArtworkActions.jsx
Normal file
136
resources/js/components/artwork/ArtworkActions.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
resources/js/components/artwork/ArtworkAuthor.jsx
Normal file
83
resources/js/components/artwork/ArtworkAuthor.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
resources/js/components/artwork/ArtworkDescription.jsx
Normal file
75
resources/js/components/artwork/ArtworkDescription.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
resources/js/components/artwork/ArtworkHero.jsx
Normal file
73
resources/js/components/artwork/ArtworkHero.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
resources/js/components/artwork/ArtworkMeta.jsx
Normal file
30
resources/js/components/artwork/ArtworkMeta.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
resources/js/components/artwork/ArtworkRelated.jsx
Normal file
44
resources/js/components/artwork/ArtworkRelated.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
resources/js/components/artwork/ArtworkStats.jsx
Normal file
42
resources/js/components/artwork/ArtworkStats.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
resources/js/components/artwork/ArtworkTags.jsx
Normal file
58
resources/js/components/artwork/ArtworkTags.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +1,96 @@
|
||||
@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
|
||||
use App\Banner;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
$authorName = $artwork->user?->name ?: $artwork->user?->username ?: null;
|
||||
$keywords = $artwork->tags->pluck('name')->merge($artwork->categories->pluck('name'))->filter()->unique()->implode(', ');
|
||||
$license = $artwork->license_url ?? null;
|
||||
|
||||
// Determine a sensible category/context for this artwork so the
|
||||
// legacy layout (sidebar + hero) can be rendered similarly to
|
||||
// category listing pages.
|
||||
$category = $artwork->categories->first() ?? null;
|
||||
$contentType = $category ? $category->contentType : null;
|
||||
$imageObject = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'ImageObject',
|
||||
'name' => (string) $artwork->title,
|
||||
'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) {
|
||||
$rootCategories = Category::where('content_type_id', $contentType->id)
|
||||
->whereNull('parent_id')
|
||||
->orderBy('sort_order')
|
||||
->get();
|
||||
} else {
|
||||
$rootCategories = collect();
|
||||
}
|
||||
$creativeWork = [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CreativeWork',
|
||||
'name' => (string) $artwork->title,
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'url' => $meta['canonical'],
|
||||
'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();
|
||||
// Provide an empty paginator to satisfy any shared pagination partials
|
||||
$artworks = new LengthAwarePaginator([], 0, 24, 1, ['path' => request()->url()]);
|
||||
$imageObject = array_filter($imageObject, static fn ($value) => $value !== null && $value !== '');
|
||||
$creativeWork = array_filter($creativeWork, static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$preloadSrcset = ($presentMd['url'] ?? '') . ' 640w, ' . ($presentLg['url'] ?? '') . ' 1280w, ' . ($presentXl['url'] ?? '') . ' 1920w';
|
||||
@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')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
<div class="pt-0">
|
||||
<div class="mx-auto w-full">
|
||||
<div class="flex min-h-[calc(100vh-64px)]">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="hidden md:block w-72 shrink-0 border-r border-neutral-800 bg-nova-900/60 backdrop-blur-sm">
|
||||
<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 id="artwork-page"
|
||||
data-artwork='@json($artworkData)'
|
||||
data-related='@json($relatedItems)'
|
||||
data-present-md='@json($presentMd)'
|
||||
data-present-lg='@json($presentLg)'
|
||||
data-present-xl='@json($presentXl)'
|
||||
data-present-sq='@json($presentSq)'
|
||||
data-cdn='@json(rtrim((string) config("cdn.files_url", "https://files.skinbase.org"), "/"))'
|
||||
data-canonical='@json($meta["canonical"])'>
|
||||
</div>
|
||||
|
||||
@php
|
||||
$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
|
||||
@vite(['resources/js/Pages/ArtworkPage.jsx'])
|
||||
@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
|
||||
|
||||
@@ -5,6 +5,25 @@
|
||||
$gridV2 = request()->query('grid') === 'v2';
|
||||
@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')
|
||||
<div class="container-fluid legacy-page">
|
||||
@php Banner::ShowResponsiveAd(); @endphp
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<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="keywords" content="{{ $page_meta_keywords ?? '' }}">
|
||||
@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'])
|
||||
<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 */
|
||||
.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; }
|
||||
|
||||
@@ -2,6 +2,25 @@
|
||||
|
||||
@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')
|
||||
<div class="container-fluid legacy-page">
|
||||
<div class="effect2 page-header-wrap">
|
||||
|
||||
@@ -1,85 +1,142 @@
|
||||
{{-- News and forum columns (migrated from legacy/home/news.blade.php) --}}
|
||||
{{-- News and forum columns --}}
|
||||
@php
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
@endphp
|
||||
|
||||
<div class="row news-row">
|
||||
<div class="col-sm-6">
|
||||
@forelse ($forumNews as $item)
|
||||
<div class="panel panel-skinbase effect2">
|
||||
<div class="panel-heading"><h4 class="panel-title">{{ $item->topic }}</h4></div>
|
||||
<div class="panel-body">
|
||||
<div class="text-muted news-head">
|
||||
Written by {{ $item->uname }} on {{ Carbon::parse($item->post_date)->format('j F Y \@ H:i') }}
|
||||
</div>
|
||||
{!! Str::limit(strip_tags($item->preview ?? ''), 240, '...') !!}
|
||||
<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
|
||||
<section class="px-6 pb-14 pt-2 md:px-10">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{{-- ── LEFT: Forum News ── --}}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="inline-flex items-center justify-center w-7 h-7 rounded-md bg-sky-500/15 text-sky-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="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>
|
||||
</span>
|
||||
<h2 class="text-base font-semibold text-white/90 tracking-wide uppercase">Forum News</h2>
|
||||
</div>
|
||||
|
||||
@if (!empty($news->picture))
|
||||
@php $nid = floor($news->news_id / 100); @endphp
|
||||
<div class="col-md-4">
|
||||
<img src="/archive/news/{{ $nid }}/{{ $news->picture }}" class="img-responsive" alt="{{ $news->headline }}">
|
||||
@forelse ($forumNews as $item)
|
||||
<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">
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $item->topic_id, 'slug' => Str::slug($item->topic ?? '')]) }}"
|
||||
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 class="col-md-8">
|
||||
{!! $news->preview !!}
|
||||
@if (!empty($item->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>
|
||||
@else
|
||||
{!! $news->preview !!}
|
||||
<p class="text-xs text-white/50 leading-relaxed line-clamp-3">{!! Str::limit(strip_tags($news->preview ?? ''), 240) !!}</p>
|
||||
@endif
|
||||
|
||||
<a class="clearfix btn btn-xs btn-info text-white" href="/news/{{ $news->news_id }}/{{ Str::slug($news->headline ?? '') }}">More</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@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
|
||||
|
||||
{{-- 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'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>
|
||||
|
||||
{{-- Latest forum activity --}}
|
||||
<div class="panel panel-default activity-panel">
|
||||
<div class="panel-heading"><strong>Latest Forum Activity</strong></div>
|
||||
<div class="panel-body">
|
||||
<div class="list-group effect2">
|
||||
{{-- About Skinbase --}}
|
||||
<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="flex items-center gap-2 mb-3">
|
||||
<span class="inline-flex items-center justify-center w-6 h-6 rounded-md bg-sky-500/20 text-sky-400">
|
||||
<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)
|
||||
<a class="list-group-item" href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}">
|
||||
{{ $topic->topic }} <span class="badge badge-info">{{ $topic->numPosts }}</span>
|
||||
<a href="{{ route('forum.thread.show', ['thread' => $topic->topic_id, 'slug' => Str::slug($topic->topic ?? '')]) }}"
|
||||
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>
|
||||
@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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>{{-- end right column --}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 --}}
|
||||
<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)
|
||||
<x-artwork-card :art="$upload" />
|
||||
@empty
|
||||
@@ -24,7 +41,7 @@
|
||||
</section>
|
||||
|
||||
@push('styles')
|
||||
@if(! $gridV2)
|
||||
@if(! ($gridV2 ?? false))
|
||||
<style>
|
||||
[data-nova-gallery].is-enhanced [data-gallery-grid] {
|
||||
display: grid;
|
||||
|
||||
@@ -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::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');
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Dashboard\ManageController;
|
||||
use App\Http\Controllers\Dashboard\ArtworkController as DashboardArtworkController;
|
||||
use App\Http\Controllers\Web\HomeController;
|
||||
use App\Http\Controllers\Web\ArtController;
|
||||
use App\Http\Controllers\Web\ArtworkPageController;
|
||||
use App\Http\Controllers\Misc\AvatarController as LegacyAvatarController;
|
||||
use App\Http\Controllers\Forum\ForumController;
|
||||
use App\Http\Controllers\Community\NewsController;
|
||||
@@ -37,7 +38,7 @@ use Inertia\Inertia;
|
||||
Route::get('/', [HomeController::class, 'index'])->name('legacy.home');
|
||||
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::get('/avatar/{id}/{name?}', [LegacyAvatarController::class, 'show'])->where('id', '\\d+')->name('legacy.avatar');
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
57
tests/Feature/HomePageTest.php
Normal file
57
tests/Feature/HomePageTest.php
Normal 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);
|
||||
});
|
||||
@@ -10,7 +10,8 @@ export default defineConfig({
|
||||
'resources/scss/nova.scss',
|
||||
'resources/js/nova.js',
|
||||
'resources/js/entry-topbar.jsx',
|
||||
'resources/js/upload.jsx'
|
||||
'resources/js/upload.jsx',
|
||||
'resources/js/Pages/ArtworkPage.jsx'
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user