feat: increase gallery grid from 4 to 5 columns per row on desktopfeat: increase gallery grid from 4 to 5 columns per row on desktop
This commit is contained in:
191
app/Services/ArtworkSearchService.php
Normal file
191
app/Services/ArtworkSearchService.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* High-level search API powered by Meilisearch via Laravel Scout.
|
||||
*
|
||||
* No Meili calls in controllers — always go through this service.
|
||||
*/
|
||||
final class ArtworkSearchService
|
||||
{
|
||||
private const BASE_FILTER = 'is_public = true AND is_approved = true';
|
||||
private const CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Full-text search with optional filters.
|
||||
*
|
||||
* Supported $filters keys:
|
||||
* tags array<string> — tag slugs (AND match)
|
||||
* category string
|
||||
* orientation string — landscape | portrait | square
|
||||
* resolution string — e.g. "1920x1080"
|
||||
* author_id int
|
||||
* sort string — created_at|downloads|likes|views (suffix :asc or :desc)
|
||||
*/
|
||||
public function search(string $q, array $filters = [], int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$filterParts = [self::BASE_FILTER];
|
||||
$sort = [];
|
||||
|
||||
if (! empty($filters['tags'])) {
|
||||
foreach ((array) $filters['tags'] as $tag) {
|
||||
$filterParts[] = 'tags = "' . addslashes((string) $tag) . '"';
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($filters['category'])) {
|
||||
$filterParts[] = 'category = "' . addslashes((string) $filters['category']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['orientation'])) {
|
||||
$filterParts[] = 'orientation = "' . addslashes((string) $filters['orientation']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['resolution'])) {
|
||||
$filterParts[] = 'resolution = "' . addslashes((string) $filters['resolution']) . '"';
|
||||
}
|
||||
|
||||
if (! empty($filters['author_id'])) {
|
||||
$filterParts[] = 'author_id = ' . (int) $filters['author_id'];
|
||||
}
|
||||
|
||||
if (! empty($filters['sort'])) {
|
||||
[$field, $dir] = $this->parseSort((string) $filters['sort']);
|
||||
if ($field) {
|
||||
$sort[] = $field . ':' . $dir;
|
||||
}
|
||||
}
|
||||
|
||||
$options = ['filter' => implode(' AND ', $filterParts)];
|
||||
if ($sort !== []) {
|
||||
$options['sort'] = $sort;
|
||||
}
|
||||
|
||||
return Artwork::search($q ?: '')
|
||||
->options($options)
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a tag page, sorted by views + likes descending.
|
||||
*/
|
||||
public function byTag(string $slug, int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
$tag = Tag::where('slug', $slug)->first();
|
||||
if (! $tag) {
|
||||
return $this->emptyPaginator($perPage);
|
||||
}
|
||||
|
||||
$cacheKey = "search.tag.{$slug}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($slug, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND tags = "' . addslashes($slug) . '"',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load artworks for a category, sorted by created_at desc.
|
||||
*/
|
||||
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
|
||||
{
|
||||
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1);
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Related artworks: same tags, different artwork, ranked by views + likes.
|
||||
* Limit 12.
|
||||
*/
|
||||
public function related(Artwork $artwork, int $limit = 12): LengthAwarePaginator
|
||||
{
|
||||
$tags = $artwork->tags()->pluck('tags.slug')->values()->all();
|
||||
|
||||
if ($tags === []) {
|
||||
return $this->popular($limit);
|
||||
}
|
||||
|
||||
$cacheKey = "search.related.{$artwork->id}";
|
||||
|
||||
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
|
||||
$tagFilters = implode(' OR ', array_map(
|
||||
fn ($t) => 'tags = "' . addslashes($t) . '"',
|
||||
$tags
|
||||
));
|
||||
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($limit);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most popular artworks by views.
|
||||
*/
|
||||
public function popular(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['views:desc', 'likes:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Most recent artworks by created_at.
|
||||
*/
|
||||
public function recent(int $perPage = 24): LengthAwarePaginator
|
||||
{
|
||||
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
|
||||
return Artwork::search('')
|
||||
->options([
|
||||
'filter' => self::BASE_FILTER,
|
||||
'sort' => ['created_at:desc'],
|
||||
])
|
||||
->paginate($perPage);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function parseSort(string $sort): array
|
||||
{
|
||||
$allowed = ['created_at', 'downloads', 'likes', 'views'];
|
||||
$parts = explode(':', $sort, 2);
|
||||
$field = $parts[0] ?? '';
|
||||
$dir = strtolower($parts[1] ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
|
||||
}
|
||||
|
||||
private function emptyPaginator(int $perPage): LengthAwarePaginator
|
||||
{
|
||||
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user