Profile: store covers in object storage (WebP); add covers config; remember artworks categories content-type preference

This commit is contained in:
2026-03-29 09:22:36 +02:00
parent cab4fbd83e
commit 1da7d3bf88
27 changed files with 703 additions and 448 deletions

View File

@@ -32,7 +32,7 @@ class DashboardGalleryController extends Controller
$artworks->getCollection()->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
);
$mainCategories = ContentType::orderBy('id')
$mainCategories = ContentType::ordered()
->get(['name', 'slug'])
->map(function (ContentType $type) {
return (object) [

View File

@@ -57,7 +57,7 @@ class BrowseController extends Controller
// Shape data for the legacy Blade while using authoritative tables only.
$artworks->getCollection()->transform(fn (Artwork $artwork) => $this->mapArtwork($artwork));
$rootCategories = ContentType::orderBy('id')
$rootCategories = ContentType::ordered()
->get(['name', 'slug'])
->map(fn (ContentType $type) => (object) [
'name' => $type->name,

View File

@@ -164,7 +164,7 @@ final class StudioController extends Controller
private function getCategories(): array
{
return ContentType::with(['rootCategories.children'])->get()->map(function ($ct) {
return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
return [
'id' => $ct->id,
'name' => $ct->name,

View File

@@ -7,11 +7,9 @@ use App\Support\CoverUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
@@ -142,9 +140,9 @@ class ProfileCoverController extends Controller
]);
}
private function storageRoot(): string
private function coverDiskName(): string
{
return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
return (string) config('covers.disk', 's3');
}
private function coverDirectory(string $hash): string
@@ -152,15 +150,12 @@ class ProfileCoverController extends Controller
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return $this->storageRoot()
. DIRECTORY_SEPARATOR . 'covers'
. DIRECTORY_SEPARATOR . $p1
. DIRECTORY_SEPARATOR . $p2;
return 'covers/' . $p1 . '/' . $p2;
}
private function coverPath(string $hash, string $ext): string
{
return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
return $this->coverDirectory($hash) . '/' . $hash . '.' . $ext;
}
/**
@@ -169,6 +164,7 @@ class ProfileCoverController extends Controller
private function storeCoverFile(UploadedFile $file): array
{
$this->assertImageManager();
$this->assertStorageIsAllowed();
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
@@ -201,18 +197,26 @@ class ProfileCoverController extends Controller
));
}
$ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp');
$image = $this->manager->read($raw);
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
$ext = 'webp';
$encoded = $this->encodeByExtension($processed, $ext);
$hash = hash('sha256', $encoded);
$dir = $this->coverDirectory($hash);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
$disk = Storage::disk($this->coverDiskName());
$written = $disk->put($this->coverPath($hash, $ext), $encoded, [
'visibility' => 'public',
'CacheControl' => 'public, max-age=31536000, immutable',
'ContentType' => match ($ext) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
default => 'image/webp',
},
]);
File::put($this->coverPath($hash, $ext), $encoded);
if ($written !== true) {
throw new RuntimeException('Unable to store cover image in object storage.');
}
return ['hash' => $hash, 'ext' => $ext];
}
@@ -220,8 +224,6 @@ class ProfileCoverController extends Controller
private function encodeByExtension($image, string $ext): string
{
return match ($ext) {
'jpg' => (string) $image->encode(new JpegEncoder(85)),
'png' => (string) $image->encode(new PngEncoder()),
default => (string) $image->encode(new WebpEncoder(85)),
};
}
@@ -235,10 +237,7 @@ class ProfileCoverController extends Controller
return;
}
$path = $this->coverPath($trimHash, $trimExt);
if (is_file($path)) {
@unlink($path);
}
Storage::disk($this->coverDiskName())->delete($this->coverPath($trimHash, $trimExt));
}
private function assertImageManager(): void
@@ -249,4 +248,16 @@ class ProfileCoverController extends Controller
throw new RuntimeException('Image processing is not available on this environment.');
}
private function assertStorageIsAllowed(): void
{
if (! app()->environment('production')) {
return;
}
$diskName = $this->coverDiskName();
if (in_array($diskName, ['local', 'public'], true)) {
throw new RuntimeException('Production cover storage must use object storage, not local/public disks.');
}
}
}

View File

@@ -10,7 +10,7 @@ class BrowseCategoriesController extends Controller
{
public function index(Request $request)
{
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->orderBy('id')->get();
$contentTypes = \App\Models\ContentType::with(['rootCategories.children'])->ordered()->get();
$categoriesByType = [];
$categories = collect();

View File

@@ -17,7 +17,7 @@ use Illuminate\Pagination\AbstractCursorPaginator;
class BrowseGalleryController extends \App\Http\Controllers\Controller
{
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other'];
private const CONTENT_TYPE_SLUGS = ['photography', 'wallpapers', 'skins', 'other', 'digital-art'];
/**
* Meilisearch sort-field arrays per sort alias.
@@ -108,7 +108,7 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Browse Artworks',
'hero_description' => 'List of all uploaded artworks across Skins, Wallpapers, Photography, and Other.',
'breadcrumbs' => collect(),
'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/browse']]),
'page_title' => 'Browse Uploaded Artworks - Photography, Wallpapers and Skins at SkinBase',
'page_meta_description' => "Browse Uploaded Photography, Wallpapers and Skins to one of the world's oldest online social community for artists and art enthusiasts.",
'page_meta_keywords' => 'photography, wallpapers, skins, stock, browse, social, community, artist, picture, photo',
@@ -164,7 +164,10 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $contentType->name,
'hero_description' => $contentType->description ?? ($contentType->name . ' artworks on Skinbase.'),
'breadcrumbs' => collect([(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug]]),
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/browse'],
(object) ['name' => $contentType->name, 'url' => '/' . $contentSlug],
]),
'page_title' => $contentType->name . ' Skinbase Nova',
'page_meta_description' => $contentType->description ?? ('Discover the best ' . $contentType->name . ' artworks on Skinbase'),
'page_meta_keywords' => strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography',
@@ -204,8 +207,17 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
$subcategories = $rootCategories;
}
$breadcrumbs = collect($category->breadcrumbs)
->map(function (Category $crumb) {
$breadcrumbs = collect(array_merge([
(object) [
'name' => 'Explore',
'url' => '/browse',
],
(object) [
'name' => $contentType->name,
'url' => '/' . $contentSlug,
],
], $category->breadcrumbs))
->map(function ($crumb) {
return (object) [
'name' => $crumb->name,
'url' => $crumb->url,
@@ -344,7 +356,8 @@ class BrowseGalleryController extends \App\Http\Controllers\Controller
private function mainCategories(): Collection
{
return ContentType::orderBy('id')
return ContentType::ordered()
->whereIn('slug', self::CONTENT_TYPE_SLUGS)
->get(['name', 'slug'])
->map(function (ContentType $type) {
return (object) [

View File

@@ -225,7 +225,7 @@ final class ExploreController extends Controller
private function mainCategories(): Collection
{
$categories = ContentType::orderBy('id')
$categories = ContentType::ordered()
->get(['name', 'slug'])
->map(fn ($ct) => (object) [
'name' => $ct->name,

View File

@@ -24,7 +24,7 @@ class SectionsController extends Controller
->orderBy('sort_order')
->orderBy('name');
},
])->orderBy('id')->get();
])->ordered()->get();
// Total artwork counts per content type via a single aggregation query
$artworkCountsByType = DB::table('artworks')

View File

@@ -73,7 +73,7 @@ final class TagController extends Controller
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
$mainCategories = ContentType::ordered()->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
'name' => $type->name,

View File

@@ -10,7 +10,16 @@ use App\Models\Artwork;
class ContentType extends Model
{
protected $fillable = ['name','slug','description'];
protected $fillable = ['name','slug','description','order'];
protected $casts = [
'order' => 'integer',
];
public function scopeOrdered(EloquentBuilder $query): EloquentBuilder
{
return $query->orderBy('order')->orderBy('name')->orderBy('id');
}
public function categories(): HasMany
{

View File

@@ -20,7 +20,7 @@ final class StudioAiCategoryMapper
$tokens = $this->tokenize($signals);
$haystack = ' ' . implode(' ', $tokens) . ' ';
$contentTypes = ContentType::query()->with(['rootCategories.children'])->get();
$contentTypes = ContentType::query()->with(['rootCategories.children'])->ordered()->get();
$contentTypeScores = $contentTypes
->map(fn (ContentType $contentType): array => $this->scoreContentType($contentType, $tokens, $haystack))
->filter(fn (array $row): bool => $row['score'] > 0)