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:
132
app/Services/ArtworkAwardService.php
Normal file
132
app/Services/ArtworkAwardService.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkAwardStat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ArtworkAwardService
|
||||
{
|
||||
/**
|
||||
* Award an artwork with the given medal.
|
||||
* Throws ValidationException if the user already awarded this artwork.
|
||||
*/
|
||||
public function award(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||
{
|
||||
$this->validateMedal($medal);
|
||||
|
||||
$existing = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
throw ValidationException::withMessages([
|
||||
'medal' => 'You have already awarded this artwork. Use change to update.',
|
||||
]);
|
||||
}
|
||||
|
||||
$award = ArtworkAward::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
]);
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
|
||||
return $award;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change an existing award medal for a user/artwork pair.
|
||||
*/
|
||||
public function changeAward(Artwork $artwork, User $user, string $medal): ArtworkAward
|
||||
{
|
||||
$this->validateMedal($medal);
|
||||
|
||||
$award = ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->firstOrFail();
|
||||
|
||||
$award->update([
|
||||
'medal' => $medal,
|
||||
'weight' => ArtworkAward::WEIGHTS[$medal],
|
||||
]);
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
|
||||
return $award->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an award for a user/artwork pair.
|
||||
*/
|
||||
public function removeAward(Artwork $artwork, User $user): void
|
||||
{
|
||||
ArtworkAward::where('artwork_id', $artwork->id)
|
||||
->where('user_id', $user->id)
|
||||
->delete();
|
||||
|
||||
$this->recalcStats($artwork->id);
|
||||
$this->syncToSearch($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate and persist stats for the given artwork.
|
||||
*/
|
||||
public function recalcStats(int $artworkId): ArtworkAwardStat
|
||||
{
|
||||
$counts = DB::table('artwork_awards')
|
||||
->where('artwork_id', $artworkId)
|
||||
->selectRaw('
|
||||
SUM(medal = \'gold\') AS gold_count,
|
||||
SUM(medal = \'silver\') AS silver_count,
|
||||
SUM(medal = \'bronze\') AS bronze_count
|
||||
')
|
||||
->first();
|
||||
|
||||
$gold = (int) ($counts->gold_count ?? 0);
|
||||
$silver = (int) ($counts->silver_count ?? 0);
|
||||
$bronze = (int) ($counts->bronze_count ?? 0);
|
||||
$score = ($gold * 3) + ($silver * 2) + ($bronze * 1);
|
||||
|
||||
$stat = ArtworkAwardStat::updateOrCreate(
|
||||
['artwork_id' => $artworkId],
|
||||
[
|
||||
'gold_count' => $gold,
|
||||
'silver_count' => $silver,
|
||||
'bronze_count' => $bronze,
|
||||
'score_total' => $score,
|
||||
'updated_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
return $stat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a non-blocking reindex for the artwork after award stats change.
|
||||
*/
|
||||
public function syncToSearch(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
private function validateMedal(string $medal): void
|
||||
{
|
||||
if (! in_array($medal, ArtworkAward::MEDALS, true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'medal' => 'Invalid medal. Must be gold, silver, or bronze.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
app/Services/ArtworkSearchIndexer.php
Normal file
61
app/Services/ArtworkSearchIndexer.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\DeleteArtworkFromIndexJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Manages Meilisearch index operations for artworks.
|
||||
*
|
||||
* All write operations are dispatched to queues — never block requests.
|
||||
*/
|
||||
final class ArtworkSearchIndexer
|
||||
{
|
||||
/**
|
||||
* Queue an artwork for indexing (insert or update).
|
||||
*/
|
||||
public function index(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue an artwork for re-indexing after an update.
|
||||
*/
|
||||
public function update(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue removal of an artwork from the index.
|
||||
*/
|
||||
public function delete(int $id): void
|
||||
{
|
||||
DeleteArtworkFromIndexJob::dispatch($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entire artworks index in background chunks.
|
||||
* Run via: php artisan artworks:search-rebuild
|
||||
*/
|
||||
public function rebuildAll(int $chunkSize = 500): void
|
||||
{
|
||||
Artwork::with(['user', 'tags', 'categories', 'stats', 'awardStat'])
|
||||
->public()
|
||||
->published()
|
||||
->orderBy('id')
|
||||
->chunk($chunkSize, function ($artworks): void {
|
||||
foreach ($artworks as $artwork) {
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
});
|
||||
|
||||
Log::info('ArtworkSearchIndexer::rebuildAll — jobs dispatched');
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ class ArtworkService
|
||||
{
|
||||
$query = Artwork::where('user_id', $userId)
|
||||
->with([
|
||||
'user:id,name',
|
||||
'user:id,name,username',
|
||||
'categories' => function ($q) {
|
||||
$q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
|
||||
->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
|
||||
|
||||
@@ -6,6 +6,17 @@ namespace App\Services;
|
||||
|
||||
final class TagNormalizer
|
||||
{
|
||||
/**
|
||||
* Normalize a raw tag string to a clean, ASCII-only slug.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Trim + lowercase
|
||||
* 2. Transliterate Unicode → ASCII (iconv or Transliterator)
|
||||
* 3. Strip everything except [a-z0-9 -]
|
||||
* 4. Collapse whitespace, replace spaces with hyphens
|
||||
* 5. Strip leading/trailing hyphens
|
||||
* 6. Enforce max length
|
||||
*/
|
||||
public function normalize(string $tag): string
|
||||
{
|
||||
$value = trim($tag);
|
||||
@@ -15,25 +26,63 @@ final class TagNormalizer
|
||||
|
||||
$value = mb_strtolower($value, 'UTF-8');
|
||||
|
||||
// Remove emoji / symbols and keep only letters, numbers, whitespace and hyphens.
|
||||
// (Unicode safe: \p{L} letters, \p{N} numbers)
|
||||
$value = (string) preg_replace('/[^\p{L}\p{N}\s\-]+/u', '', $value);
|
||||
// Transliterate to ASCII (e.g. é→e, ü→u, 日→nihon).
|
||||
// Try Transliterator first (intl extension), fall back to iconv.
|
||||
if (class_exists('\Transliterator')) {
|
||||
$trans = \Transliterator::create('Any-Latin; Latin-ASCII; Lower()');
|
||||
if ($trans !== null) {
|
||||
$value = (string) ($trans->transliterate($value) ?: $value);
|
||||
}
|
||||
} elseif (function_exists('iconv')) {
|
||||
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
|
||||
if ($ascii !== false && $ascii !== '') {
|
||||
$value = $ascii;
|
||||
}
|
||||
}
|
||||
|
||||
// Keep only ASCII letters, digits, spaces and hyphens.
|
||||
$value = (string) preg_replace('/[^a-z0-9\s\-]+/', '', $value);
|
||||
|
||||
// Normalize whitespace.
|
||||
$value = (string) preg_replace('/\s+/u', ' ', $value);
|
||||
$value = (string) preg_replace('/\s+/', ' ', $value);
|
||||
$value = trim($value);
|
||||
|
||||
// Spaces -> hyphens and collapse repeats.
|
||||
// Spaces → hyphens, collapse repeats, strip edge hyphens.
|
||||
$value = str_replace(' ', '-', $value);
|
||||
$value = (string) preg_replace('/\-+/u', '-', $value);
|
||||
$value = trim($value, "-\t\n\r\0\x0B");
|
||||
$value = (string) preg_replace('/-+/', '-', $value);
|
||||
$value = trim($value, '-');
|
||||
|
||||
$maxLength = (int) config('tags.max_length', 32);
|
||||
if ($maxLength > 0 && mb_strlen($value, 'UTF-8') > $maxLength) {
|
||||
$value = mb_substr($value, 0, $maxLength, 'UTF-8');
|
||||
if ($maxLength > 0 && strlen($value) > $maxLength) {
|
||||
$value = substr($value, 0, $maxLength);
|
||||
$value = rtrim($value, '-');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a normalized slug back to a human-readable display name.
|
||||
*
|
||||
* "blue-sky" → "Blue Sky"
|
||||
* "sci-fi-landscape" → "Sci Fi Landscape"
|
||||
* "3d" → "3D"
|
||||
*
|
||||
* If the raw input is available, pass it instead of the slug — it gives
|
||||
* better casing (e.g. the AI sends "digital painting", no hyphens yet).
|
||||
*/
|
||||
public function toDisplayName(string $slugOrRaw): string
|
||||
{
|
||||
// If raw input still has mixed case or spaces, title-case it directly.
|
||||
$clean = trim($slugOrRaw);
|
||||
if ($clean === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Replace hyphens and underscores with spaces for word splitting.
|
||||
$spaced = str_replace(['-', '_'], ' ', $clean);
|
||||
|
||||
// Title-case each word (mb_convert_case handles UTF-8 safely).
|
||||
return mb_convert_case($spaced, MB_CASE_TITLE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Services\TagNormalizer;
|
||||
@@ -19,14 +20,17 @@ final class TagService
|
||||
|
||||
public function createOrFindTag(string $rawTag): Tag
|
||||
{
|
||||
$normalized = $this->normalizer->normalize($rawTag);
|
||||
$normalized = $this->normalizer->normalize($rawTag);
|
||||
$this->validateNormalizedTag($normalized);
|
||||
|
||||
// Keep tags normalized in both name and slug (spec: normalize all tags).
|
||||
// Unique(slug) + Unique(name) prevents duplicates.
|
||||
// Derive display name from the clean slug, not the raw input.
|
||||
// This ensures consistent casing regardless of how the tag was submitted.
|
||||
// "digital-art" → "Digital Art", "sci-fi-landscape" → "Sci Fi Landscape"
|
||||
$displayName = $this->normalizer->toDisplayName($normalized);
|
||||
|
||||
return Tag::query()->firstOrCreate(
|
||||
['slug' => $normalized],
|
||||
['name' => $normalized, 'usage_count' => 0, 'is_active' => true]
|
||||
['name' => $displayName, 'usage_count' => 0, 'is_active' => true]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,6 +87,8 @@ final class TagService
|
||||
$artwork->tags()->updateExistingPivot($tagId, $payload);
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,6 +153,8 @@ final class TagService
|
||||
$this->incrementUsageCounts($newlyAttachedTagIds);
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
public function detachTags(Artwork $artwork, array $tagSlugsOrIds): void
|
||||
@@ -179,6 +187,8 @@ final class TagService
|
||||
$artwork->tags()->detach($existing);
|
||||
$this->decrementUsageCounts($existing);
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,6 +246,8 @@ final class TagService
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->queueReindex($artwork);
|
||||
}
|
||||
|
||||
public function updateUsageCount(Tag $tag): void
|
||||
@@ -326,4 +338,13 @@ final class TagService
|
||||
->whereIn('id', $tagIds)
|
||||
->update(['usage_count' => DB::raw('CASE WHEN usage_count > 0 THEN usage_count - 1 ELSE 0 END')]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a non-blocking reindex job for the given artwork.
|
||||
* Called after every tag mutation so the search index stays consistent.
|
||||
*/
|
||||
private function queueReindex(Artwork $artwork): void
|
||||
{
|
||||
IndexArtworkJob::dispatch($artwork->id);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user