diff --git a/app/Http/Controllers/Api/ArtworkInteractionController.php b/app/Http/Controllers/Api/ArtworkInteractionController.php new file mode 100644 index 00000000..a7d5bde0 --- /dev/null +++ b/app/Http/Controllers/Api/ArtworkInteractionController.php @@ -0,0 +1,193 @@ +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, + ], + ]; + } +} diff --git a/app/Http/Controllers/Web/ArtController.php b/app/Http/Controllers/Web/ArtController.php index f9991fa2..5f564e1b 100644 --- a/app/Http/Controllers/Web/ArtController.php +++ b/app/Http/Controllers/Web/ArtController.php @@ -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); - } + $art = \App\Models\Artwork::find((int) $id); + 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()) { diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php new file mode 100644 index 00000000..aa999bca --- /dev/null +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -0,0 +1,116 @@ +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, + ]); + } +} diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index fb23ff1b..d80ea230 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -1,8 +1,10 @@ resource instanceof MissingValue) { - return []; + $md = ThumbnailPresenter::present($this->resource, 'md'); + $lg = ThumbnailPresenter::present($this->resource, 'lg'); + $xl = ThumbnailPresenter::present($this->resource, 'xl'); + $sq = ThumbnailPresenter::present($this->resource, 'sq'); + + $canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title)); + if ($canonicalSlug === '') { + $canonicalSlug = (string) $this->id; } - $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', ''), '/'); - $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; + $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(); + } - return sprintf('%s/originals/%s/%s/%s.%s', $filesBase, $h1, $h2, $normalizedHash, $normalizedExt); - }; + $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(), ]; } } diff --git a/app/Services/AvatarService.php b/app/Services/AvatarService.php index 643dddd1..3dcc1610 100644 --- a/app/Services/AvatarService.php +++ b/app/Services/AvatarService.php @@ -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; } } diff --git a/app/Services/ThumbnailPresenter.php b/app/Services/ThumbnailPresenter.php index 2179b4a8..62892c0e 100644 --- a/app/Services/ThumbnailPresenter.php +++ b/app/Services/ThumbnailPresenter.php @@ -1,28 +1,75 @@ 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); } } diff --git a/app/Services/ThumbnailService.php b/app/Services/ThumbnailService.php index 83d7dd36..31ac50a9 100644 --- a/app/Services/ThumbnailService.php +++ b/app/Services/ThumbnailService.php @@ -1,20 +1,38 @@ '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'; - $h = $hash; + $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'; } } diff --git a/database/migrations/2026_02_22_120000_create_artwork_likes_table.php b/database/migrations/2026_02_22_120000_create_artwork_likes_table.php new file mode 100644 index 00000000..462cacc1 --- /dev/null +++ b/database/migrations/2026_02_22_120000_create_artwork_likes_table.php @@ -0,0 +1,26 @@ +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'); + } +}; diff --git a/database/migrations/2026_02_22_120100_create_artwork_reports_table.php b/database/migrations/2026_02_22_120100_create_artwork_reports_table.php new file mode 100644 index 00000000..13f47b4b --- /dev/null +++ b/database/migrations/2026_02_22_120100_create_artwork_reports_table.php @@ -0,0 +1,28 @@ +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'); + } +}; diff --git a/public/js/legacy-gallery-init.js b/public/js/legacy-gallery-init.js index a7bd7b9b..49ae902b 100644 --- a/public/js/legacy-gallery-init.js +++ b/public/js/legacy-gallery-init.js @@ -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,39 +90,39 @@ }); } - 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 = ''; - }); - 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'; - } else { - 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 }); }); } + // 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 { + // Capture the last-known rendered height before hiding. + // 'auto 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) { var parser = new DOMParser(); var doc = parser.parseFromString(html, 'text/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(); diff --git a/public/legacy/js/legacy-gallery-init.js b/public/legacy/js/legacy-gallery-init.js index a7bd7b9b..49ae902b 100644 --- a/public/legacy/js/legacy-gallery-init.js +++ b/public/legacy/js/legacy-gallery-init.js @@ -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,39 +90,39 @@ }); } - 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 = ''; - }); - 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'; - } else { - 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 }); }); } + // 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 { + // Capture the last-known rendered height before hiding. + // 'auto 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) { var parser = new DOMParser(); var doc = parser.parseFromString(html, 'text/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(); diff --git a/resources/css/nova-grid.css b/resources/css/nova-grid.css index b3279795..3ec32b37 100644 --- a/resources/css/nova-grid.css +++ b/resources/css/nova-grid.css @@ -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; +} diff --git a/resources/js/Pages/ArtworkPage.jsx b/resources/js/Pages/ArtworkPage.jsx new file mode 100644 index 00000000..3b1596f7 --- /dev/null +++ b/resources/js/Pages/ArtworkPage.jsx @@ -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 ( +
+ + +
+ +
+ +
+
+ + + + + +
+ + +
+ + +
+ ) +} + +// 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( + , + ) +} + +export default ArtworkPage diff --git a/resources/js/components/artwork/ArtworkActions.jsx b/resources/js/components/artwork/ArtworkActions.jsx new file mode 100644 index 00000000..769116a9 --- /dev/null +++ b/resources/js/components/artwork/ArtworkActions.jsx @@ -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 ( +
+

Actions

+
+ + Download + + + + + + + + + +
+ + {mobilePriority && ( +
+ + Download + +
+ )} +
+ ) +} diff --git a/resources/js/components/artwork/ArtworkAuthor.jsx b/resources/js/components/artwork/ArtworkAuthor.jsx new file mode 100644 index 00000000..76d639f9 --- /dev/null +++ b/resources/js/components/artwork/ArtworkAuthor.jsx @@ -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 ( +
+

Author

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

@{user.username}

} +

{followersCount.toLocaleString()} followers

+
+
+ +
+ + Profile + + +
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkDescription.jsx b/resources/js/components/artwork/ArtworkDescription.jsx new file mode 100644 index 00000000..99943ca0 --- /dev/null +++ b/resources/js/components/artwork/ArtworkDescription.jsx @@ -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({rest.slice(lastIndex, match.index)}) + } + + parts.push( + + {match[1]} + , + ) + + lastIndex = match.index + match[0].length + match = linkPattern.exec(rest) + } + + if (lastIndex < rest.length) { + parts.push({rest.slice(lastIndex)}) + } + + return ( +

+ {parts} +

+ ) + }) +} + +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 ( +
+

Description

+
{rendered}
+ + {content.length > COLLAPSE_AT && ( + + )} +
+ ) +} diff --git a/resources/js/components/artwork/ArtworkHero.jsx b/resources/js/components/artwork/ArtworkHero.jsx new file mode 100644 index 00000000..58e6687c --- /dev/null +++ b/resources/js/components/artwork/ArtworkHero.jsx @@ -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 ( +
+
+ {blurBackdropSrc && ( +
+ +
+ )} + + {hasRealArtworkImage && ( +
+ )} + +
+ {artwork?.title + + {artwork?.title setIsLoaded(true)} + onError={(event) => { + event.currentTarget.src = FALLBACK_LG + }} + /> +
+ + {hasRealArtworkImage && ( +
+ )} +
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkMeta.jsx b/resources/js/components/artwork/ArtworkMeta.jsx new file mode 100644 index 00000000..be806a96 --- /dev/null +++ b/resources/js/components/artwork/ArtworkMeta.jsx @@ -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 ( +
+

{artwork?.title}

+
+
+
Author
+
{author}
+
+
+
Upload date
+
{publishedAt}
+
+
+
Resolution
+
{width > 0 && height > 0 ? `${width} × ${height}` : '—'}
+
+
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkRelated.jsx b/resources/js/components/artwork/ArtworkRelated.jsx new file mode 100644 index 00000000..8b10d810 --- /dev/null +++ b/resources/js/components/artwork/ArtworkRelated.jsx @@ -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 ( +
+

Related Artworks

+ +
+ {related.slice(0, 12).map((item) => ( + + ))} +
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkStats.jsx b/resources/js/components/artwork/ArtworkStats.jsx new file mode 100644 index 00000000..5e93aecb --- /dev/null +++ b/resources/js/components/artwork/ArtworkStats.jsx @@ -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 ( +
+

Statistics

+
+
+
👁 Views
+
{formatCount(stats.views)} views
+
+
+
⬇️ Downloads
+
{formatCount(stats.downloads)} downloads
+
+
+
❤️ Likes
+
{formatCount(stats.likes)} likes
+
+
+
⭐ Favorites
+
{formatCount(stats.favorites)} favorites
+
+
+
Resolution
+
{width > 0 && height > 0 ? `${width} × ${height}` : '—'}
+
+
+
+ ) +} diff --git a/resources/js/components/artwork/ArtworkTags.jsx b/resources/js/components/artwork/ArtworkTags.jsx new file mode 100644 index 00000000..f5daf81b --- /dev/null +++ b/resources/js/components/artwork/ArtworkTags.jsx @@ -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 ( +
+
+

Tags & Categories

+ {tags.length > 12 && ( + + )} +
+ +
+ {visible.map((tag) => ( + + {tag.label} + + ))} +
+
+ ) +} diff --git a/resources/views/artworks/show.blade.php b/resources/views/artworks/show.blade.php index 805ddc04..426fabe8 100644 --- a/resources/views/artworks/show.blade.php +++ b/resources/views/artworks/show.blade.php @@ -1,266 +1,96 @@ @extends('layouts.nova') -@php - use App\Banner; - use App\Models\Category; - use Illuminate\Pagination\LengthAwarePaginator; +@push('head') + {{ $meta['title'] }} + + - // 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; + + + + + + @if(!empty($meta['og_image'])) + + + @if(!empty($meta['og_width'])) + + @endif + @if(!empty($meta['og_height'])) + + @endif + @endif - if ($contentType) { - $rootCategories = Category::where('content_type_id', $contentType->id) - ->whereNull('parent_id') - ->orderBy('sort_order') - ->get(); - } else { - $rootCategories = collect(); - } + + + + @if(!empty($meta['og_image'])) + + @endif - $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()]); -@endphp + @php + $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; + + $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, + ]; + + $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, + ]; + + $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'])) + + @endif + + + +@endpush @section('content') -
- @php Banner::ShowResponsiveAd(); @endphp - -
-
-
- - - - - -
-
-
- -
-
- @if($contentType) - {{ $contentType->name }} - @endif - @if($category) - @foreach ($category->breadcrumbs as $crumb) - {{ $crumb->name }} - @endforeach - @endif -
- - @php - $breadcrumbs = $category ? (is_array($category->breadcrumbs) ? $category->breadcrumbs : [$category]) : []; - $headerCategory = !empty($breadcrumbs) ? end($breadcrumbs) : ($category ?? null); - @endphp - -

{{ $headerCategory->name ?? $artwork->title }}

- -
-
-
{{ $artwork->title }}
-

{!! $artwork->description ?? ($headerCategory->description ?? ($contentType->name ?? 'Artwork')) !!}

-
-
- -
-
- - -
-
-
-
- {{ $artwork->title }} -
-
- -
- - @if(isset($similarItems) && $similarItems->isNotEmpty()) -
-
-

Similar artworks

-
- - -
- @endif -
-
-
-
+
-
-@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 - -@if(isset($similarItems) && $similarItems->isNotEmpty()) - -@endif + @vite(['resources/js/Pages/ArtworkPage.jsx']) @endsection - -@push('styles') - -@endpush diff --git a/resources/views/gallery/index.blade.php b/resources/views/gallery/index.blade.php index ce0fcce8..c0fec693 100644 --- a/resources/views/gallery/index.blade.php +++ b/resources/views/gallery/index.blade.php @@ -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') + + @if($seoPrev)@endif + @if($seoNext)@endif + +@endpush + @section('content')
@php Banner::ShowResponsiveAd(); @endphp diff --git a/resources/views/layouts/nova.blade.php b/resources/views/layouts/nova.blade.php index 01848386..edfc7745 100644 --- a/resources/views/layouts/nova.blade.php +++ b/resources/views/layouts/nova.blade.php @@ -8,6 +8,7 @@ + @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'])