fixed gallery

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

View File

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

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use 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()) {

View File

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

View File

@@ -1,8 +1,10 @@
<?php
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 [];
$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(),
];
}
}