Profile: store covers in object storage (WebP); add covers config; remember artworks categories content-type preference
This commit is contained in:
@@ -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) [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user