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:
2026-02-25 19:11:23 +01:00
parent 5c97488e80
commit 0032aec02f
131 changed files with 15674 additions and 597 deletions

View 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.',
]);
}
}
}

View 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');
}
}

View 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);
}
}

View File

@@ -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']);

View File

@@ -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');
}
}

View File

@@ -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);
}
}